Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions claim_contracts/Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.PHONY: help \
calldata-update-merkle-root calldata-update-limit-timestamp calldata-approve-spending calldata-unpause calldata-pause \
deploy-token deploy-token-sepolia deploy-token-mainnet \
deploy-claimable-local deploy-claimable-sepolia deploy-claimable-base-sepolia deploy-claimable-mainnet \
deploy-claimable-local deploy-claimable-sepolia deploy-claimable-base-sepolia deploy-claimable-mainnet deploy-claimable-base-mainnet \
update_token_proxy upgrade-token enable-claimability \
approve-claimable claimable-update-root claimable-get-root claimable-update-timestamp claimable-get-timestamp claimable-pause claimable-unpause \
upgrade-aligned-token-implementation \
Expand All @@ -12,15 +12,18 @@
# ============================================================================
# Configuration — override any of these on the command line, e.g.
# make deploy-claimable-sepolia DEPLOYER_PRIVATE_KEY=0x... ETHERSCAN_API_KEY=...
# (ETHERSCAN_API_KEY, KEYSTORE_PATH, MERKLE_ROOT, TIMESTAMP, CLAIM_PROXY_ADDRESS,
# (ETHERSCAN_API_KEY, MERKLE_ROOT, TIMESTAMP, CLAIM_PROXY_ADDRESS,
# LIMIT_TIMESTAMP, etc. have no default and are passed in per invocation.)
# The mainnet deploy targets sign with --interactive, so they prompt for the key instead of
# reading it from a variable or a keystore path.
# ============================================================================

# RPC endpoints (per network)
RPC_URL ?= http://localhost:8545
SEPOLIA_RPC_URL ?= https://ethereum-sepolia-rpc.publicnode.com
BASE_SEPOLIA_RPC_URL ?= https://sepolia.base.org
MAINNET_RPC_URL ?= https://ethereum-rpc.publicnode.com
BASE_MAINNET_RPC_URL ?= https://mainnet.base.org

# Signing keys (anvil defaults, for local use)
DEPLOYER_PRIVATE_KEY ?= 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
Expand Down Expand Up @@ -48,8 +51,8 @@ calldata-update-merkle-root: ## 💾 Calldata for the method `updateMerkleRoot`
calldata-update-limit-timestamp: ## 💾 Calldata for the method `extendClaimPeriod` to use in a transaction
cast calldata "extendClaimPeriod(uint256)" $(LIMIT_TIMESTAMP)

calldata-approve-spending: ## 💾 Calldata for the method `approve` to use in a transaction
cast calldata "approve(address,uint256)" $(CLAIM_PROXY_ADDRESS) 2600000000000000000000000000
calldata-approve-spending: ## 💾 Calldata for the method `approve` to use in a transaction (override APPROVE_AMOUNT)
cast calldata "approve(address,uint256)" $(CLAIM_PROXY_ADDRESS) $(APPROVE_AMOUNT)

calldata-unpause: ## 💾 Calldata for the method `unpause` to use in a transaction
cast calldata "unpause()"
Expand Down Expand Up @@ -83,9 +86,11 @@ deploy-token-mainnet: ## 🚀 Deploy the token contract in Mainnet
forge script DeployAlignedToken.s.sol \
--sig "run(string)" \
mainnet \
--keystore $(KEYSTORE_PATH) \
--interactive \
--rpc-url $(MAINNET_RPC_URL) \
--broadcast \
--verify \
--etherscan-api-key $(ETHERSCAN_API_KEY) \
--verbosity 3

deploy-claimable-local: ## 🚀 Deploy the airdrop contract in localnet
Expand Down Expand Up @@ -120,12 +125,22 @@ deploy-claimable-mainnet: ## 🚀 Deploy the airdrop contract in Mainnet
cd script && \
forge script DeployClaimableAirdrop.s.sol \
--sig "run(string)" mainnet \
--keystore $(KEYSTORE_PATH) \
--interactive \
--rpc-url $(MAINNET_RPC_URL) \
--broadcast \
--verify \
--etherscan-api-key $(ETHERSCAN_API_KEY)

deploy-claimable-base-mainnet: ## 🚀 Deploy the airdrop contract in Base Mainnet
cd script && \
forge script DeployClaimableAirdrop.s.sol \
--sig "run(string)" base-mainnet \
--interactive \
--rpc-url $(BASE_MAINNET_RPC_URL) \
--broadcast \
--verify \
--etherscan-api-key $(ETHERSCAN_API_KEY)


# TODO: broken — reads script-out/deployed_token_addresses.json (never generated by the deploy
# scripts) and treats $(CONFIG) as a file path. Fix or remove.
Expand Down
148 changes: 127 additions & 21 deletions claim_contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,27 @@ Each environment below is a single flow: **deploy → enable claiming**.
## Prerequisites

- [Foundry](https://book.getfoundry.sh/getting-started/installation).
- A funded deployer account private key (a keystore for mainnet).
- A funded deployer account private key. The mainnet targets sign with `--interactive`, so they
prompt for the key rather than reading a variable or a keystore path.
- An Etherscan API key to verify the deployed contract (the same key works for Base via the
Etherscan v2 API).

### Who signs what on mainnet

On mainnet every privileged action goes through a Safe, and **each chain has its own pair of safes**.
The Ethereum foundation safe may not be the Base foundation safe; the same holds for the
distributors. Don't assume they match — fill each chain's config from that chain's safes rather than
copying addresses across.

| | Ethereum mainnet | Base mainnet |
| --- | --- | --- |
| **Foundation** safe — claim contract owner + proxy admin owner | `foundation` in `config.mainnet.json` | `foundation` in `config.base-mainnet.json` |
| **Distributor** safe — holds the tokens, approves the claim contract | `tokenDistributor` in `config.mainnet.json` | `tokenDistributor` in `config.base-mainnet.json` |

Deployment is the one exception: `forge script` signs with an ordinary **hot EOA**, which needs no
privileges at all — ownership is assigned from the config's `foundation` at initialization, not from
the deployer. Don't try to run a deploy from a Safe.

## Local (anvil)

A single anvil chain that deploys the token + claim contract and turns claiming on.
Expand Down Expand Up @@ -88,27 +105,90 @@ which it is right after deployment. Each step is also available as its own targe

## Mainnet

Covers Ethereum mainnet today. **Base mainnet: to be added later** (no claim target yet).
Both chains: **Ethereum mainnet** and **Base mainnet**. Each needs its own claim contract, its own
merkle root, and its own pair of safes (see [Who signs what on mainnet](#who-signs-what-on-mainnet)).
The ALIGN token already exists on both — you only deploy and enable `ClaimableAirdrop`.

Run Ethereum first, then Base; the two are independent apart from sharing the same
`START_TIMESTAMP` and claim deadline.

### 1. Deploy (Ethereum mainnet)
### Ethereum mainnet

Fill `script-config/config.mainnet.json` with `foundation`, `tokenDistributor`, and `tokenProxy`
(the mainnet ALIGN token), then:
#### 1. Deploy

Fill `script-config/config.mainnet.json` with the Ethereum `foundation` and `tokenDistributor` safes.
`tokenProxy` is already the mainnet ALIGN token.

```
make deploy-claimable-mainnet KEYSTORE_PATH=<keystore-path> ETHERSCAN_API_KEY=<key>
make deploy-claimable-mainnet ETHERSCAN_API_KEY=<key>
```

Note the claimable proxy address printed in the output.
Prompts for the deployer key. Note the claimable **proxy** address printed in the output (not the
implementation).

> [!NOTE]
> The mainnet ALIGN token already exists — use its address as `tokenProxy`. To deploy the token
> from scratch on Ethereum: `make deploy-token-mainnet KEYSTORE_PATH=<keystore-path>`.
> To deploy the token from scratch on Ethereum: `make deploy-token-mainnet ETHERSCAN_API_KEY=<key>`.
> You should not need this — the ALIGN token is already deployed.

#### 2. Enable claiming

Follow [Enable claiming from the safes](#enable-claiming-from-the-safes) with:

- `AIRDROP` = the claim proxy you just deployed, `TOKEN` = the mainnet ALIGN token
- `MERKLE_ROOT` = the **ethereum** root
- Owner steps from the **Ethereum foundation** safe, approve from the **Ethereum distributor** safe

### Base mainnet

#### 1. Token — already deployed, but its supply has to be bridged

The Base ALIGN token is an `OptimismMintableERC20` created through the OP factory, not with forge —
see [`base/`](base/README.md). It is pre-filled as `tokenProxy` in
`script-config/config.base-mainnet.json`.

Its balance on Base only exists to the extent it has been **bridged from Ethereum**. Claims call
`safeTransferFrom` on the distributor, so before enabling claiming the **Base distributor safe** must
hold at least the total claimable on Base.

#### 2. Fund the Base distributor safe

The **Ethereum distributor** safe bridges to the **Base distributor** safe. Since the two may be
different addresses, the deposit has to name the destination explicitly — otherwise the tokens land
at whatever address matches the sender on Base, which may be one nobody controls. Full walkthrough in
[`base/`](base/README.md#bridge-l1---base).

Takes ~20 minutes to land. Verify before continuing:

```
cast call <base-align-token> 'balanceOf(address)(uint256)' <base-distributor-safe> \
--rpc-url https://mainnet.base.org
```

#### 3. Deploy

Fill `script-config/config.base-mainnet.json` with the **Base** `foundation` and `tokenDistributor`
safes, then:

```
make deploy-claimable-base-mainnet ETHERSCAN_API_KEY=<key>
```

Note the claimable proxy address printed in the output.

#### 4. Enable claiming

Follow [Enable claiming from the safes](#enable-claiming-from-the-safes) with:

### 2. Enable claiming (foundation multisig)
- `AIRDROP` = the Base claim proxy, `TOKEN` = the Base ALIGN token
- `MERKLE_ROOT` = the **base** root
- Owner steps from the **Base foundation** safe, approve from the **Base distributor** safe
- `APPROVE_AMOUNT` = the amount actually bridged, not the 2.6B default

On mainnet the owner is the foundation safe, so you generate the calldata for each step and execute
it from the multisig rather than sending the transactions directly.
### Enable claiming from the safes

The owner is a Safe, so you generate the calldata for each step and execute it from the multisig
rather than sending transactions directly. This section applies to **either** chain — use that
chain's safes, root, token and claim proxy throughout.

> [!IMPORTANT]
>
Expand All @@ -118,20 +198,38 @@ it from the multisig rather than sending the transactions directly.
> anything.
> - Steps 1, 2 and 4 are owner actions and can be batched in one multisig transaction. Step 3 must
> be done by the token-distributor safe (it holds the tokens).
> - `unpause` goes **last**: `updateMerkleRoot` and `extendClaimPeriod` are `whenPaused`.

> [!WARNING]
> Double-check the data you pass into these commands — any mistake can lead to undesired behavior.
> In particular, use the **ethereum** root on the Ethereum contract and the **base** root on the
> Base contract. Crossed roots deploy and enable without complaint and fail only when a user claims,
> with `Invalid Merkle proof`.

1. Merkle root, from the foundation safe:
`make calldata-update-merkle-root MERKLE_ROOT=<that-chain's-root>`
2. Claim deadline, from the foundation safe:
`make calldata-update-limit-timestamp LIMIT_TIMESTAMP=<timestamp>`
3. Approve spending, from the token-distributor safe, **sent to that chain's token contract**:
`make calldata-approve-spending CLAIM_PROXY_ADDRESS=<claimable-proxy> APPROVE_AMOUNT=<amount>`
(`APPROVE_AMOUNT` defaults to 2.6B; on Base pass the bridged amount instead)
4. Unpause, from the foundation safe: `make calldata-unpause`

1. Merkle root (use the **ethereum** root for the mainnet contract):
`make calldata-update-merkle-root MERKLE_ROOT=<root>`
2. Claim deadline: `make calldata-update-limit-timestamp LIMIT_TIMESTAMP=<timestamp>`
3. Approve spending, run by the token-distributor safe:
`make calldata-approve-spending CLAIM_PROXY_ADDRESS=<claimable-proxy>`
4. Unpause: `make calldata-unpause`
Submit each piece of calldata as a transaction from the appropriate safe.

Submit each piece of calldata as a transaction from the appropriate safe. The same per-network root
mapping (ethereum root on the Ethereum contract, base root on the Base contract) applies once Base
mainnet is added.
### Verify (either chain, read-only)

```
make claimable-get-root AIRDROP=<claim-proxy> RPC_URL=<rpc> # that chain's root
make claimable-get-timestamp AIRDROP=<claim-proxy> RPC_URL=<rpc> # the claim deadline
make test-airdrop AIRDROP=<claim-proxy> RPC_URL=<rpc> # paused()=false, owner()=safe

cast call <token> 'allowance(address,address)(uint256)' \
<distributor-safe> <claim-proxy> --rpc-url <rpc>
```

`RPC_URL` has no mainnet default — pass `https://ethereum-rpc.publicnode.com` or
`https://mainnet.base.org` explicitly, or the read hits `http://localhost:8545`.

## Upgrades

Expand All @@ -140,6 +238,14 @@ To upgrade a contract, first make sure you pause the contract if it's not paused
> [!NOTE]
> The ERC20 cannot be paused. Only the claimable airdrop proxy can be paused.

> [!IMPORTANT]
> Upgrades are **per chain**. Ethereum mainnet and Base mainnet have separate proxies, separate proxy
> admins and separate foundation safes, so an upgrade has to be repeated on each chain with that
> chain's config (`config.mainnet.json` / `config.base-mainnet.json`), and the final transaction must
> be submitted from that chain's foundation safe. Note the upgrade script below reads a different
> shape from the deploy scripts — `foundation` plus `contractProxy` — so don't overwrite the
> `tokenDistributor`/`tokenProxy` values a redeploy would need.

```
git clone git@github.com:yetanotherco/aligned_layer.git && cd aligned_layer/claim_contracts
```
Expand Down
Loading
Loading