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
22 changes: 13 additions & 9 deletions docs/operations/pools.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,14 @@ forge script \
The address is saved to `history/token-pools/{selectorName}/{timestamp}-{SYMBOL}-BurnMintTokenPool.json`
(keys `{CHAIN_NAME_IDENTIFIER}_TOKEN_POOL` and `{CHAIN_NAME_IDENTIFIER}_TOKEN`).

Set `POOL_HOOKS=0x...` to attach an `AdvancedPoolHooks` contract at deploy time. Set `DECIMALS=<n>` if
your token does not implement the optional `decimals()` ERC20 function; the script falls back to this
value and fails if neither is available. The script also attempts `grantMintAndBurnRoles` on the token
to grant the pool mint and burn rights; if the token does not implement it, the script prints
instructions to grant the roles manually.
Set `POOL_HOOKS=0x...` to attach an `AdvancedPoolHooks` contract at deploy time. The pool's decimals
value comes from the token's `decimals()` when it answers; `decimals()` is optional in ERC20, so for a
token without it set `DECIMALS=<n>` explicitly - the pool takes the value as a constructor argument by
design and treats an on-chain read only as a cross-check. When both sources exist they must agree, and
when neither does the deploy stops: the value is immutable and scales every amount the pool moves, so
it is never guessed. The script also attempts `grantMintAndBurnRoles` on the token to grant the pool
mint and burn rights; if the token does not implement it, the script prints instructions to grant the
roles manually.

## Lock and release pool

Expand All @@ -72,10 +75,11 @@ lockbox must authorize the pool before it can deposit or withdraw tokens. The de
lockbox, then pool, then authorize the pool on the lockbox.

`LOCK_BOX` is required and must be the address of a deployed `ERC20LockBox` for the token. Set
`POOL_HOOKS=0x...` to attach an already-deployed `AdvancedPoolHooks` contract. Set `DECIMALS=<n>` if
your token does not implement `decimals()`. When deploying the lockbox, optionally set
`AUTHORIZED_CALLERS` (CSV or JSON array) to authorize addresses immediately, useful for letting the
deployer or token issuer deposit and withdraw initial liquidity.
`POOL_HOOKS=0x...` to attach an already-deployed `AdvancedPoolHooks` contract. Decimals resolve the same
way as for the BurnMint pool above: read from the token when `decimals()` answers, `DECIMALS=<n>` for a
token without the optional getter, agreement required when both exist, refusal when neither. When
deploying the lockbox, optionally set `AUTHORIZED_CALLERS` (CSV or JSON array) to authorize addresses
immediately, useful for letting the deployer or token issuer deposit and withdraw initial liquidity.

The golden-path targets `make deploy-lockbox CHAIN=<name> VERIFY=1` and
`make deploy-lockrelease-pool CHAIN=<name> VERIFY=1` resolve the token and lock box from the registry;
Expand Down
8 changes: 7 additions & 1 deletion script/config/AdoptToken.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,13 @@ contract AdoptToken is Script {
plan.chainId = chainId;
plan.token = token;
plan.pool = pool;
plan.tokenSymbol = DeploymentUtils._getSymbol(vm, token);
// The symbol keys every entry this adoption writes, so a defaulted one collides with any other
// token on this chain whose symbol could not be read, and the later adoption overwrites the
// earlier. Both look like ordinary entries afterwards, so require one that came from the token
// or from TOKEN_SYMBOL.
(bool symbolOk, string memory tokenSymbol) = DeploymentUtils._trySymbol(vm, token);
require(symbolOk, "The token supplied no usable symbol: set TOKEN_SYMBOL to the symbol to adopt it under");
plan.tokenSymbol = tokenSymbol;

console.log("");
console.log("========================================");
Expand Down
8 changes: 7 additions & 1 deletion script/configure/GetLockBox.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ contract GetLockBox is Script {

console.log(string.concat(" Token: ", vm.toString(tokenAddress), symbol));
console.log(string.concat(" Balance: ", vm.toString(balance)));
} catch {}
} catch {
// Catching silently would leave a lockbox whose token cannot be read looking like one
// with nothing worth printing. The lockbox address above holds either way, so name
// the part that is missing.
console.log(" Token: could not be read from the lockbox");
console.log(" Balance: unknown (it is read through the token)");
}
}
} catch (bytes memory err) {
console.log(unicode"❌ Error: getLockBox() reverted.");
Expand Down
39 changes: 35 additions & 4 deletions script/configure/allowlist/GetAllowList.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,41 @@ contract GetAllowList is Script {
console.log("========================================");
console.log("");

address[] memory allowList = AdvancedPoolHooks(hooksAddress).getAllowList();
console.log(string.concat("AllowList count: ", vm.toString(allowList.length)));
for (uint256 i = 0; i < allowList.length; i++) {
console.log(string.concat(" ", vm.toString(allowList[i])));
// The list alone is ambiguous: an empty one means every sender is permitted when the hooks were
// deployed without an allowlist, and nobody may send when they were deployed with one and it was
// since emptied. Print the enforcement state so the list can be read.
bool enforced;
try AdvancedPoolHooks(hooksAddress).getAllowListEnabled() returns (bool enabled) {
enforced = enabled;
} catch {
console.log(unicode"❓ Could not read the allowlist state at this address.");
console.log(" Without it, an allowlist read here cannot be interpreted.");
console.log(
string.concat(" Confirm POOL_HOOKS is an AdvancedPoolHooks contract: ", vm.toString(hooksAddress))
);
console.log("========================================");
console.log("");
// The revert carries the failure into the exit code: printing an error and exiting 0
// would tell any wrapper reading it that the allowlist state was read.
revert("getAllowListEnabled() could not be read (see above)");
}
if (!enforced) {
console.log(unicode"⚠️ These hooks enforce NO allowlist: every sender is permitted.");
console.log(" Enforcement is fixed at deployment and cannot be turned on later. To restrict");
console.log(" senders, deploy AdvancedPoolHooks with a non-empty ALLOWLIST and point the");
console.log(" pool at it.");
} else {
// Name the enforcement state rather than leave it implied by the absence of the warning
// above: with it, a count of zero reads as what it is.
address[] memory allowList = AdvancedPoolHooks(hooksAddress).getAllowList();
console.log("Allowlist enforcement: ON");
console.log(string.concat("AllowList count: ", vm.toString(allowList.length)));
if (allowList.length == 0) {
console.log(unicode"⚠️ The list is enforced and empty: NO sender is permitted.");
}
for (uint256 i = 0; i < allowList.length; i++) {
console.log(string.concat(" ", vm.toString(allowList[i])));
}
}
console.log("========================================");
console.log(string.concat("Pool Hooks: ", helperConfig.getExplorerUrl(chainId, "/address/", hooksAddress)));
Expand Down
38 changes: 38 additions & 0 deletions script/configure/allowlist/IsAllowListed.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,44 @@ contract IsAllowListed is Script {
console.log("========================================");
console.log("");

// Enforcement decides what a membership check can mean. `checkAllowList` is a no-op while the
// allowlist is disabled: it returns without reverting for every address, 0x0 included, so a
// non-revert says nothing until enforcement is established. Enforcement is fixed at deployment
// (`i_allowlistEnabled = allowlist.length > 0`, immutable), so hooks deployed with an empty
// allowlist can never enforce one.
bool enforced;
try AdvancedPoolHooks(hooksAddress).getAllowListEnabled() returns (bool enabled) {
enforced = enabled;
} catch {
console.log(unicode"❓ Could not read the allowlist state at this address.");
console.log(" Without it, nothing can be reported about CHECK_ADDRESS.");
console.log(
string.concat(" Confirm POOL_HOOKS is an AdvancedPoolHooks contract: ", vm.toString(hooksAddress))
);
console.log("========================================");
console.log("");
// The revert carries the failure into the exit code: printing an error and exiting 0
// would tell any wrapper reading it that the allowlist state was read.
revert("getAllowListEnabled() could not be read (see above)");
}

if (!enforced) {
console.log(
unicode"⚠️ These hooks enforce NO allowlist: every sender is permitted, this one included."
);
console.log(" Enforcement is fixed at deployment and cannot be turned on later. To restrict");
console.log(" senders, deploy AdvancedPoolHooks with a non-empty ALLOWLIST and point the");
console.log(" pool at it.");
console.log("========================================");
console.log(
string.concat("Pool Hooks: ", helperConfig.getExplorerUrl(chainId, "/address/", hooksAddress))
);
console.log("========================================");
console.log("");
return;
}

// Enforcement is on, so a revert now carries the membership answer and nothing else.
bool isAllowListed = false;
try AdvancedPoolHooks(hooksAddress).checkAllowList(checkAddress) {
isAllowListed = true;
Expand Down
8 changes: 8 additions & 0 deletions script/configure/allowlist/UpdateAllowList.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ contract UpdateAllowList is EoaExecutor {
address[] memory removes = HelperUtils._parseAddressArray(vm, vm.envOr("REMOVE_ADDRESSES", string("")), "");
address[] memory adds = HelperUtils._parseAddressArray(vm, vm.envOr("ADD_ADDRESSES", string("")), "");

// With neither variable set both arrays parse empty, and the target accepts that as a no-op: the
// run would report success and link a real transaction, so a mistyped variable name would look
// like an applied change. Refuse before broadcasting.
require(
removes.length + adds.length > 0,
"No allowlist changes given. Set ADD_ADDRESSES and/or REMOVE_ADDRESSES (CSV or JSON array)."
);

console.log("");
console.log("========================================");
console.log(unicode"📝 Update AllowList");
Expand Down
10 changes: 8 additions & 2 deletions script/configure/finality-config/GetFinalityConfig.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,17 @@ contract GetFinalityConfig is Script {
try tokenPool.getAllowedFinalityConfig() returns (bytes4 allowedFinality) {
FinalityConfigUtils._logFinalityConfig(allowedFinality);
} catch (bytes memory err) {
// A revert here has several causes: a pool older than TokenPool 2.0.0, an address that is not
// a CCIP pool, or bytecode this run cannot execute. The raw data is printed so the reader can
// tell which, and the revert is re-raised rather than swallowed, because printing an error
// and exiting 0 tells any wrapper reading the exit code that the read succeeded.
console.log(
unicode"❌ Error: getAllowedFinalityConfig() reverted. Pool may be v1 (requires TokenPool v2.0+)."
unicode"❌ Error: getAllowedFinalityConfig() reverted. The pool may predate TokenPool 2.0.0, this"
);
console.log(" Raw revert data:");
console.log(" address may not be a CCIP token pool, or this run may not be able to execute its");
console.log(" bytecode. Raw revert data:");
console.logBytes(err);
revert("getAllowedFinalityConfig() reverted (see raw revert data above)");
}

// ── Footer ─────────────────────────────────────────────────────────
Expand Down
18 changes: 9 additions & 9 deletions script/deploy/DeployBurnMintTokenPool.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import {HelperConfig} from "../HelperConfig.s.sol"; // Network configuration hel
import {BurnMintTokenPool} from "@chainlink/contracts-ccip/contracts/pools/BurnMintTokenPool.sol";
import {CrossChainToken} from "@chainlink/contracts-ccip/contracts/tokens/CrossChainToken.sol";
import {IBurnMintERC20} from "@chainlink/contracts-ccip/contracts/interfaces/IBurnMintERC20.sol";
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import {DeploymentUtils} from "../utils/DeploymentUtils.s.sol";
import {DeploymentRecorder} from "../utils/DeploymentRecorder.s.sol";
import {RegistryWriter} from "../../src/utils/RegistryWriter.sol";

/// @notice Deploys a BurnMint token pool for a token and records it in the address registry.
/// DECIMALS=<n> is required only when the token does not answer decimals(), and must agree with it when
/// both exist; the resolved value is the pool's immutable scaling factor.
contract DeployBurnMintTokenPool is Script {
HelperConfig public helperConfig;

Expand Down Expand Up @@ -46,14 +47,13 @@ contract DeployBurnMintTokenPool is Script {
require(config.router != address(0), "Router not defined for this network");
require(config.rmnProxy != address(0), "RMN Proxy not defined for this network");

// decimals() is optional in ERC20; fall back to DECIMALS env var if not present
uint8 decimals;
try IERC20Metadata(tokenAddress).decimals() returns (uint8 d) {
decimals = d;
} catch {
console.log(unicode"⚠️ decimals() not found on token, falling back to DECIMALS env var");
decimals = uint8(vm.envUint("DECIMALS"));
}
// decimals() when the token answers, an explicit DECIMALS when it does not (the getter is
// optional in ERC20 and the pool takes the value as a constructor argument by design), a
// mismatch or a missing-on-both-sides refusal otherwise. Never a guess: the value is immutable
// and scales every amount the pool moves.
uint8 decimals = DeploymentUtils._resolveTokenDecimals(
vm, tokenAddress, vm.envOr("DECIMALS", DeploymentUtils.DECIMALS_UNSET)
);
// POOL_HOOKS alias > {CHAIN}_POOL_HOOKS > registry active.poolHooks. Optional (0x0 = no hooks).
address poolHooks = vm.envOr("POOL_HOOKS", helperConfig.getDeployedPoolHooks(chainId));

Expand Down
18 changes: 9 additions & 9 deletions script/deploy/DeployLockReleaseTokenPool.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {Script, console} from "forge-std/Script.sol";
import {HelperConfig} from "../HelperConfig.s.sol"; // Network configuration helper
import {LockReleaseTokenPool} from "@chainlink/contracts-ccip/contracts/pools/LockReleaseTokenPool.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import {DeploymentUtils} from "../utils/DeploymentUtils.s.sol";
import {DeploymentRecorder} from "../utils/DeploymentRecorder.s.sol";
import {RegistryWriter} from "../../src/utils/RegistryWriter.sol";

/// @notice Deploys a LockRelease token pool (paired with an ERC20 LockBox) and records it in the registry.
/// DECIMALS=<n> is required only when the token does not answer decimals(), and must agree with it when
/// both exist; the resolved value is the pool's immutable scaling factor.
contract DeployLockReleaseTokenPool is Script {
HelperConfig public helperConfig;

Expand Down Expand Up @@ -50,14 +51,13 @@ contract DeployLockReleaseTokenPool is Script {
require(config.router != address(0), "Router not defined for this network");
require(config.rmnProxy != address(0), "RMN Proxy not defined for this network");

// decimals() is optional in ERC20; fall back to DECIMALS env var if not present
uint8 decimals;
try IERC20Metadata(tokenAddress).decimals() returns (uint8 d) {
decimals = d;
} catch {
console.log(unicode"⚠️ decimals() not found on token, falling back to DECIMALS env var");
decimals = uint8(vm.envUint("DECIMALS"));
}
// decimals() when the token answers, an explicit DECIMALS when it does not (the getter is
// optional in ERC20 and the pool takes the value as a constructor argument by design), a
// mismatch or a missing-on-both-sides refusal otherwise. Never a guess: the value is immutable
// and scales every amount the pool moves.
uint8 decimals = DeploymentUtils._resolveTokenDecimals(
vm, tokenAddress, vm.envOr("DECIMALS", DeploymentUtils.DECIMALS_UNSET)
);
// POOL_HOOKS alias > {CHAIN}_POOL_HOOKS > registry active.poolHooks. Optional (0x0 = no hooks).
address poolHooks = vm.envOr("POOL_HOOKS", helperConfig.getDeployedPoolHooks(chainId));

Expand Down
16 changes: 13 additions & 3 deletions script/diagnostics/PreflightTransfer.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ interface IPoolToken {
/// word, e.g. `0x0000000100000000000000000000000000000000000000000000000000000000`, to select the
/// fast-finality inbound bucket), TOKEN_ARGS (bytes, IPoolV2 lock only).
///
/// ORIGINAL_SENDER is the account the source pool gates on (allowlist, policy engine). Left at its
/// default (RECEIVER) it asks whether the receiver could send to themselves, so set it whenever
/// someone else sends. Otherwise a clean GO here can still be followed by a live SenderNotAllowed.
///
/// Usage:
/// make preflight SOURCE_CHAIN=ethereum-testnet-sepolia DEST_CHAIN=avalanche-fuji AMOUNT=10000 RECEIVER=0xYou
/// # raw forge (the make recipe sets the two RPC URLs from each chain's rpcEnv):
Expand Down Expand Up @@ -114,6 +118,7 @@ contract PreflightTransfer is Script, StdCheats {
console.log(string.concat("Dest pool: ", vm.toString(ctx.destPool)));
console.log(string.concat("Amount: ", vm.toString(ctx.amount)));
console.log(string.concat("Receiver: ", vm.toString(ctx.receiver)));
console.log(string.concat("Sender: ", vm.toString(ctx.originalSender)));

(bytes memory destPoolData, uint256 releaseAmount) = _simulateLockOrBurn(ctx);
_simulateReleaseOrMint(ctx, destPoolData, releaseAmount);
Expand Down Expand Up @@ -210,14 +215,14 @@ contract PreflightTransfer is Script, StdCheats {
try IPoolV2(ctx.destPool).releaseOrMint(input, ctx.requestedFinality) returns (
Pool.ReleaseOrMintOutV1 memory out
) {
_go(out.destinationAmount);
_go(out.destinationAmount, ctx.originalSender);
} catch (bytes memory reason) {
_noGoRevert("destination releaseOrMint", ctx.destPool, localToken, reason);
}
} else {
vm.prank(offRamp);
try IPoolV1(ctx.destPool).releaseOrMint(input) returns (Pool.ReleaseOrMintOutV1 memory out) {
_go(out.destinationAmount);
_go(out.destinationAmount, ctx.originalSender);
} catch (bytes memory reason) {
_noGoRevert("destination releaseOrMint", ctx.destPool, localToken, reason);
}
Expand Down Expand Up @@ -248,9 +253,14 @@ contract PreflightTransfer is Script, StdCheats {
}
}

function _go(uint256 destinationAmount) internal pure {
/// @dev The verdict names the sender it simulated. The source pool gates on `originalSender` (the
/// allowlist, and any policy engine), so a GO holds for that account only: simulate one address
/// and send from another, and the same lane can strand on `SenderNotAllowed` straight after a
/// clean preflight.
function _go(uint256 destinationAmount, address originalSender) internal pure {
console.log("=========================================");
console.log(unicode"✅ GO: both pool legs simulate cleanly; this transfer would execute.");
console.log(string.concat(" simulated sender: ", vm.toString(originalSender)));
console.log(string.concat(" destinationAmount (local decimals): ", vm.toString(destinationAmount)));
}

Expand Down
Loading
Loading