MOVEUSD and the mintfx holds on EVM chains: the Foundry sibling of mintfx-program. One deployment per chain, all on the same addresses through CREATE2.
src/MoveUSD.sol (UUPS): ERC-20, 6 decimals, ERC-2612 permit, ERC-3009 (transferWithAuthorization, receiveWithAuthorization, cancelAuthorization, in src/ERC3009Upgradeable.sol), burnable, pausable, supply capped at type(uint64).max. MINTER_ROLE holds mint(address,uint256), the selector NTT's INttToken.mint calls; NttManager in burning mode calls burn(uint256) after pulling a transfer. No ERC20Votes.src/MintFx.sol (UUPS): mint straight to targets, and the redemption and withdrawal holds. Roles mirror the Solana program's AuthorityPermission flags, less the retired Transfer. Events carry the Solana events' names and fields.src/CfxVerifyingPaymaster.sol: ERC-4337 paymaster for EntryPoint v0.8 (0x4337084D…f108) that sponsors UserOps a CFX signer approved, for EIP-7702-delegated bip39 EOAs.script/Deploy.s.sol: timelock, both proxies and the paymaster through the deterministic deployer, then the role grants. Holds no keys; addresses come from the environment (the header lists them).src/generated/: the ABIs as as const objects, checked in, the package's only TS export.redeem / withdraw ──► Submitted ──► commitRedeem / commitWithdrawal ──► Committed
└───────► voidRedeem / voidWithdrawal ──────► Voided
bytes16 ik backs one operation of any kind, ever: a mint, redemption or withdrawal claims it, and a terminal state keeps it claimed. A redemption ik cannot be committed or voided as a withdrawal.receiveWithAuthorization signed by the wallet: from the wallet, to MintFx, for exactly the hold's amount, with nonce holdNonce(kind, ik) (keccak256(abi.encode(uint8 kind, bytes16 ik))). One signature opens one hold of one kind, as Solana requires the wallet's signature on every create_*. No allowance is ever used, so a standing approval to MintFx cannot fund a hold. Only MintFx can submit the authorization (to == msg.sender), so it cannot be front-run into a plain transfer. The wallet signs typed data only and the platform pays gas.commitRedeem pays the fees from the hold and burns the rest; fees must leave something to burn, as Solana's amount_after_fees requires. commitWithdrawal fans the hold out to targets that must sum to it exactly.PAUSE_ROLE, UNPAUSE_ROLE, separate) stops every operation, like validate_is_not_paused. Pausing MoveUSD stops every transfer, mint and burn, NTT's included.DEPLOYER) grants the operating roles at deploy and renounces DEFAULT_ADMIN_ROLE in the same script. A TimelockController then holds DEFAULT_ADMIN_ROLE and UPGRADER_ROLE on both proxies, so a new minter or an upgrade waits out its delay.msg.sender is still the role's EOA: the EntryPoint calls execute on it, and it makes the call.CfxVerifyingPaymaster has one DEFAULT_ADMIN_ROLE holder (OZ AccessControlDefaultAdminRules): it sets the signer and runs BasePaymaster's owner functions (withdrawTo, stake), and moves only by a two-step transfer after PAYMASTER_ADMIN_DELAY. owner() returns that holder; Ownable's transfer functions revert.Every address depends only on SALT and the init code. The timelock and the paymaster take only cross-chain inputs. MoveUSD's and MintFx's init calldata carry the bootstrap admin, so one MoveUSD address on every chain requires the same DEPLOYER on every chain: deploy every chain from the same DEPLOYER. test/Deploy.t.sol holds both.
paymasterAndData is paymaster ‖ uint128 verificationGas ‖ uint128 postOpGas ‖ abi.encode(uint48 validUntil, uint48 validAfter) ‖ signature, the signature exactly 65 bytes (no EIP-2098 compact form). The signer signs getHash(userOp, validUntil, validAfter) as an EIP-191 personal message. EntryPoint v0.8 rejects an op while block.timestamp <= validAfter, so open the window at least one second in the past. getHash does not bind a 7702 sender's delegate: initCode carries only the 0x7702 marker, so an approval survives the sender re-delegating.
npm packages in the root package.json, resolved through remappings.txt to ../../node_modules: @openzeppelin/contracts and -upgradeable 5.6.1, @account-abstraction/contracts 0.8.0, forge-std from its GitHub tag (the npm copy dates from 2022). @openzeppelin/contracts-v4 (4.8.1) serves only the vendored NTT code. No git submodules.
test/ntt/ is cfx-mintfx-ntt's evm/src NttManager v1.1.0 (CFXLabsInc/cfx-mintfx-ntt at 6aa1d5f) and the wormhole-solidity-sdk files it imports (at b9e129e), Apache-2.0, copied unmodified for test/NttCompat.t.sol. Neither ships in a deployment.
main.yml's mintfx-evm / plan and mintfx-evm / release publish mintfx-evm-v<semver> (never a mintfx-v tag, which is Solana's) when the package is affected and a contract's bytes moved. One release serves every chain: the bytes are the same everywhere and only constructor arguments differ.
mintfx-evm.manifest.json and <Contract>.json (ABI, creationCode, deployedBytecode, immutableReferences) for MoveUSD, MintFx, CfxVerifyingPaymaster, TimelockController and ERC1967Proxy. scripts/build-release-manifest.ts writes them from out/.runtimeHash is keccak256 of the runtime with every immutable range zeroed. The manifest names each immutable (__self, entryPoint) with its ranges, since solc numbers AST ids per build. Whoever compares deployed code zeroes the same ranges, then checks what it zeroed: __self must be the implementation's own address, entryPoint EntryPoint v0.8.storageLayout empty, so ast = true (bytecode unchanged) lets scripts/check-namespace-layout.ts read each @custom:storage-location struct along the contracts' bases. A release fails if a member moved, was removed or changed type against the previous manifest. Appending is the one safe change. It also fails on any plain state variable in MoveUSD's or MintFx's storageLayout (recorded as the manifest's storage), and when the newest published release's manifest asset is not uploaded, rather than comparing against an older release.cfx-mintfx-evm-releases-<stage> under <tag>/, written once. The release job waits for main.yml's dev deploy, which creates the dev bucket, copies the assets there and then publishes. Prod's bucket exists only once a prod deploy has made it, so deploy.yml's mintfx-evm releases (prod) job runs after every prod deploy: it copies each published release that has an object missing from prod's bucket, taking the assets from the GitHub release. Until the next prod deploy, prod's dashboard can list a release that its bucket lacks. wallet-api refuses an op on that release with RELEASE_NOT_FOUND before it signs anything, and the run stops without a retry. wallet-api reads the copy and checks the creation code hash; it never fetches GitHub. The bucket policy denies any PutObject under mintfx-evm-v* without If-None-Match, so no principal can overwrite a release. A retried release whose manifest differs only in commit counts as already copied.scripts/next-release-version.ts over mintfx-evm-v* tags and the conventional commits touching this package. nx.json's release stays mintfx-program's. Dispatch main.yml with mintfx_evm_specifier to force one.config/chains.dev.json and chains.prod.json, exported as @cfxlabsinc/mintfx-program-evm/chains, are the one chain list per stage: each holds only its own network. Addresses are pinned once from initialRelease and never predicted on read, because a proxy's address depends on its first implementation's. timelockMinDelay and config/<env>.env's TIMELOCK_MIN_DELAY are 0 on every stage while this is in development; the timelock and its schedule, execute and cancel still run, and the delay is raised on a deployed chain through the timelock itself.
bun nx run mintfx-program-evm:test: test-forge, test-unit (the release scripts and chain files) and check-generated-client.bun nx run mintfx-program-evm:gen-abis: rewrite src/generated after a contract change, then commit it.forge build / forge test -vv in this directory for a quick loop.Foundry is pinned in .github/actions/setup-foundry (1.2.1, with the tarball checksums) and installed by both Checks jobs when this package is affected. No target is named build, so main.yml's build job never needs it. solc 0.8.30, evm_version = "cancun", bytecode_hash = "none" and no CBOR metadata, so the bytecode reproduces.