diff --git a/claim_contracts/Makefile b/claim_contracts/Makefile index 1be359724..05998b30c 100644 --- a/claim_contracts/Makefile +++ b/claim_contracts/Makefile @@ -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 \ @@ -12,8 +12,10 @@ # ============================================================================ # 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) @@ -21,6 +23,7 @@ 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 @@ -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()" @@ -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 @@ -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. diff --git a/claim_contracts/README.md b/claim_contracts/README.md index 52dfc736c..0c1fba33f 100644 --- a/claim_contracts/README.md +++ b/claim_contracts/README.md @@ -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. @@ -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= ETHERSCAN_API_KEY= +make deploy-claimable-mainnet ETHERSCAN_API_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=`. +> To deploy the token from scratch on Ethereum: `make deploy-token-mainnet ETHERSCAN_API_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 'balanceOf(address)(uint256)' \ + --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= +``` + +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] > @@ -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=` +2. Claim deadline, from the foundation safe: + `make calldata-update-limit-timestamp LIMIT_TIMESTAMP=` +3. Approve spending, from the token-distributor safe, **sent to that chain's token contract**: + `make calldata-approve-spending CLAIM_PROXY_ADDRESS= APPROVE_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=` -2. Claim deadline: `make calldata-update-limit-timestamp LIMIT_TIMESTAMP=` -3. Approve spending, run by the token-distributor safe: - `make calldata-approve-spending CLAIM_PROXY_ADDRESS=` -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= RPC_URL= # that chain's root +make claimable-get-timestamp AIRDROP= RPC_URL= # the claim deadline +make test-airdrop AIRDROP= RPC_URL= # paused()=false, owner()=safe + +cast call 'allowance(address,address)(uint256)' \ + --rpc-url +``` + +`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 @@ -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 ``` diff --git a/claim_contracts/base/README.md b/claim_contracts/base/README.md index f5e583704..c1ae292fc 100644 --- a/claim_contracts/base/README.md +++ b/claim_contracts/base/README.md @@ -8,32 +8,91 @@ Deployment and bridging of the Aligned Token (ALIGN) on Base using the [OP Stand cp .env.example .env ``` +`.env` holds the token/bridge addresses and, for the **testnet** targets, `DEPLOYER_PRIVATE_KEY` and +`USER_PRIVATE_KEY`. The **mainnet** targets ignore those and sign with `--interactive`, prompting for +the key instead — see [Mainnet is operated from safes](#mainnet-is-operated-from-safes). + ## Deploy -The L2 token is created via the `OptimismMintableERC20Factory` predeploy at `0x4200000000000000000000000000000000000012`. No custom contract is needed. +The L2 token is an `OptimismMintableERC20` created by a factory — no custom contract is needed. The +factory differs per network: + +| Network | `OptimismMintableERC20Factory` | | +| --- | --- | --- | +| Base Sepolia | `0x4200000000000000000000000000000000000012` | the OP predeploy | +| Base mainnet | `0xF10122D428B4bc8A9d050D06a2037259b4c4B83B` | Base-specific, avoids address conflicts with Optimism ([Base docs](https://docs.base.org/chain/base-contracts)) | + +Both addresses exist on Base mainnet, so using the predeploy there would create the token from the +wrong factory. The Makefile already picks the right one per target: ```bash make deploy-base-sepolia # BaseSepolia make deploy-base-mainnet # BaseMainnet ``` +> [!IMPORTANT] +> The L1 token address passed at creation becomes the L2 token's `REMOTE_TOKEN`, and it is +> **immutable**. Get it wrong and the only fix is creating a new L2 token — the bridge will never +> connect the two. Run the verification below before doing anything else with the address. + ## Verify +Required after every deploy, not optional: + ```bash -make verify L2_TOKEN=
RPC_URL=https://sepolia.base.org +make verify L2_TOKEN=
RPC_URL=https://mainnet.base.org ``` +Check that `REMOTE_TOKEN()` is exactly the intended L1 token and `BRIDGE()` is +`0x4200000000000000000000000000000000000010`. + +`totalSupply()` will be **0**. That is expected: an `OptimismMintableERC20` is minted only by the +bridge, so the L2 supply is precisely what has been bridged. Until then any transfer of the token on +Base — including a claim — reverts. + +## Mainnet is operated from safes + +On mainnet the token holder is a Safe, and **the Ethereum safe and the Base safe may be different +addresses**. Two consequences: + +- The `cast send` targets here are the **testnet / EOA** path. A Safe cannot run them; submit the + equivalent call from the Safe instead, using the parameters given below. +- Never use the plain `bridge-l1-to-base-*` or `withdraw-base-to-l1-*` form from a Safe. They call + `depositERC20` / `withdraw`, which credit **`msg.sender` on the destination chain** — for a Safe + that is an address it does not control there. Use the `-to` variants, naming the destination + chain's safe. + ## Bridge (L1 -> Base) -Approve + deposit in one command. `AMOUNT` is the token amount with 18 decimals (1000000000000000000 = 1 ALIGN). +`AMOUNT` is the token amount with 18 decimals (1000000000000000000 = 1 ALIGN). Tokens appear on Base +after ~2 minutes. + +### From a safe (mainnet) + +Two transactions, both from the **Ethereum distributor safe**: + +1. On the **L1 ALIGN token** — let the bridge move the tokens: + `approve(0x3154Cf16ccdb4C6d922629664174b904d80F2C35, )` +2. On the **L1StandardBridge** `0x3154Cf16ccdb4C6d922629664174b904d80F2C35`: + `depositERC20To(, , , , 200000, 0x)` + +The third argument is the destination on Base. It must be the **Base** safe, not the Ethereum one. + +Confirm the deposit landed: + +```bash +cast call 'balanceOf(address)(uint256)' --rpc-url https://mainnet.base.org +``` + +### From an EOA (testnet) + +Approve + deposit in one command: ```bash make bridge-l1-to-base-sepolia AMOUNT=1000000000000000000 make bridge-l1-to-base-mainnet AMOUNT=1000000000000000000 ``` -Tokens appear on Base after ~20 minutes. - To bridge to a different L2 address, use the `TO` parameter: ```bash @@ -45,6 +104,15 @@ make bridge-l1-to-base-mainnet-to AMOUNT=1000000000000000000 TO=0x... Withdrawals are a [multi-step process](https://docs.optimism.io/app-developers/tutorials/bridging/cross-dom-bridge-erc20#withdraw-tokens). No approval is needed. All three steps use the same `TX_HASH` — the **L2 initiation tx hash** from step 1. +> [!IMPORTANT] +> Only step 1 needs the safe. From the **Base distributor safe**, call on the L2StandardBridge +> `0x4200000000000000000000000000000000000010`: +> `withdrawTo(, , , 200000, 0x)`. +> Steps 2 and 3 credit the address recorded in step 1 no matter who submits them, so they run from +> any funded EOA — that is what `USER_PRIVATE_KEY` in `.env` is for. Save the step 1 tx hash before +> going further; without it you cannot prove or finalize, and the 7-day clock only starts once. +> Rehearse the whole flow on Sepolia first: a mainnet withdrawal is a week-long commitment. + 1. **Initiate** on L2 (burns tokens on Base): ```bash @@ -88,11 +156,16 @@ Source: [Base Contracts](https://docs.base.org/chain/base-contracts) ## Deployed Addresses +The canonical ALIGN token per network. Test or throwaway deployments do not belong in this table. + | Network | L1 Token (Ethereum) | L2 Token (Base) | |---------|---------------------|-----------------| | Sepolia | `0xd2Fd114f098b355321cB3424400f3CC6a0d75C9A` | `0x4AAcFbc2C31598a560b285dB20966E00B73F9F81` | | Mainnet | `0x50614cc8e44f7814549c223aa31db9296e58057c` | `0x53f39e5C53EE40bbc3Da97C3B47BD2968d110a8D` | +These are also the `tokenProxy` values in `../script-config/config.*.json`, which the claim contract +deploys read — see [`../README.md`](../README.md). + ## References - [OP Standard Bridge Standard Token Tutorial](https://docs.optimism.io/app-developers/tutorials/bridging/standard-bridge-standard-token) diff --git a/claim_contracts/script-config/config.base-mainnet.json b/claim_contracts/script-config/config.base-mainnet.json new file mode 100644 index 000000000..51aa42253 --- /dev/null +++ b/claim_contracts/script-config/config.base-mainnet.json @@ -0,0 +1,5 @@ +{ + "foundation": "", + "tokenDistributor": "", + "tokenProxy": "0x53f39e5C53EE40bbc3Da97C3B47BD2968d110a8D" +} diff --git a/claim_contracts/script-config/config.mainnet.json b/claim_contracts/script-config/config.mainnet.json index 7b2cabbf7..a28d5fca4 100644 --- a/claim_contracts/script-config/config.mainnet.json +++ b/claim_contracts/script-config/config.mainnet.json @@ -1,5 +1,5 @@ { "foundation": "", "tokenDistributor": "", - "tokenProxy": "0x0000000000000000000000000000000000000000" + "tokenProxy": "0x50614CC8e44F7814549c223aA31db9296e58057c" }