From 17fc3738389cbc09eca2c46965dbddce75f232c9 Mon Sep 17 00:00:00 2001 From: FlowMemory HQ Agent Date: Thu, 14 May 2026 13:42:14 -0500 Subject: [PATCH] Add production L1 bridge-base implementation snapshot --- contracts/bridge/BaseBridgeLockbox.sol | 46 +- .../production-l1-bridge/ASSET_DECISION.md | 28 + .../production-l1-bridge/CHECKLIST.md | 86 ++ .../production-l1-bridge/CONTRACT_PROOF.md | 56 + .../CREDIT_APPLICATION_PROOF.md | 44 + .../DEPLOYMENT_READINESS_PROOF.md | 47 + .../DEPOSIT_EVENT_PROOF.md | 47 + .../production-l1-bridge/EXPERIMENTS.md | 18 + .../FULL_MOCK_PILOT_PROOF.md | 46 + .../production-l1-bridge/HANDOFF.md | 107 ++ .../LIVE_LOW_LATENCY_RELAY.md | 117 ++ .../LIVE_OBSERVATION_GATE_PROOF.md | 49 + .../LIVE_READINESS_PROOF.md | 40 + .../MOCK_PILOT_E2E_PROOF.md | 49 + docs/agent-runs/production-l1-bridge/NOTES.md | 15 + .../OWNER_LIVE_TEST_COMMANDS.md | 96 ++ docs/agent-runs/production-l1-bridge/PLAN.md | 31 + .../REAL_FUNDS_PILOT_RUNBOOK.md | 133 ++ .../production-l1-bridge/RELAYER_PROOF.md | 51 + .../production-l1-bridge/REPLAY_PROOF.md | 35 + .../WITHDRAW_RELEASE_PROOF.md | 66 + docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md | 331 ++--- ...ase8453-pilot-duplicate-mock-deposits.json | 41 + .../bridge/base8453-pilot-mock-deposit.json | 18 + .../bridge/local-runtime-bridge-handoff.json | 43 +- .../bridge-base-mainnet-pilot-observe.ps1 | 141 ++ infra/scripts/bridge-base8453-control.ps1 | 73 + infra/scripts/bridge-base8453-deploy.ps1 | 222 +++ infra/scripts/bridge-base8453-relay.ps1 | 85 ++ infra/scripts/bridge-evidence-export.ps1 | 91 ++ infra/scripts/flowchain-no-secret-scan.mjs | 67 + package.json | 26 +- .../bridge-local-usage-proof.schema.json | 115 ++ .../flowmemory/bridge-observation.schema.json | 24 +- .../bridge-pilot-evidence.schema.json | 113 ++ .../bridge-release-evidence.schema.json | 54 + ...ntime-credit-application-state.schema.json | 35 + ...dge-runtime-credit-application.schema.json | 40 + .../bridge-runtime-handoff.schema.json | 14 +- ...ridge-withdrawal-authorization.schema.json | 105 ++ services/bridge-relayer/package.json | 4 + .../src/base8453-relay-mock-e2e.ts | 120 ++ .../src/base8453-relay-monitor.ts | 1236 +++++++++++++++++ .../src/base8453-tx-diagnostic.ts | 359 +++++ .../src/bridge-live-readiness-check.ts | 219 +++ .../bridge-relayer/src/bridge-pilot-e2e.ts | 581 ++++++++ .../src/observe-base-lockbox.ts | 810 ++++++++++- .../test/bridge-relayer.test.ts | 560 +++++++- tests/bridge/BaseBridgeLockbox.t.sol | 72 +- 49 files changed, 6532 insertions(+), 274 deletions(-) create mode 100644 docs/agent-runs/production-l1-bridge/ASSET_DECISION.md create mode 100644 docs/agent-runs/production-l1-bridge/CHECKLIST.md create mode 100644 docs/agent-runs/production-l1-bridge/CONTRACT_PROOF.md create mode 100644 docs/agent-runs/production-l1-bridge/CREDIT_APPLICATION_PROOF.md create mode 100644 docs/agent-runs/production-l1-bridge/DEPLOYMENT_READINESS_PROOF.md create mode 100644 docs/agent-runs/production-l1-bridge/DEPOSIT_EVENT_PROOF.md create mode 100644 docs/agent-runs/production-l1-bridge/EXPERIMENTS.md create mode 100644 docs/agent-runs/production-l1-bridge/FULL_MOCK_PILOT_PROOF.md create mode 100644 docs/agent-runs/production-l1-bridge/HANDOFF.md create mode 100644 docs/agent-runs/production-l1-bridge/LIVE_LOW_LATENCY_RELAY.md create mode 100644 docs/agent-runs/production-l1-bridge/LIVE_OBSERVATION_GATE_PROOF.md create mode 100644 docs/agent-runs/production-l1-bridge/LIVE_READINESS_PROOF.md create mode 100644 docs/agent-runs/production-l1-bridge/MOCK_PILOT_E2E_PROOF.md create mode 100644 docs/agent-runs/production-l1-bridge/NOTES.md create mode 100644 docs/agent-runs/production-l1-bridge/OWNER_LIVE_TEST_COMMANDS.md create mode 100644 docs/agent-runs/production-l1-bridge/PLAN.md create mode 100644 docs/agent-runs/production-l1-bridge/REAL_FUNDS_PILOT_RUNBOOK.md create mode 100644 docs/agent-runs/production-l1-bridge/RELAYER_PROOF.md create mode 100644 docs/agent-runs/production-l1-bridge/REPLAY_PROOF.md create mode 100644 docs/agent-runs/production-l1-bridge/WITHDRAW_RELEASE_PROOF.md create mode 100644 fixtures/bridge/base8453-pilot-duplicate-mock-deposits.json create mode 100644 fixtures/bridge/base8453-pilot-mock-deposit.json create mode 100644 infra/scripts/bridge-base-mainnet-pilot-observe.ps1 create mode 100644 infra/scripts/bridge-base8453-control.ps1 create mode 100644 infra/scripts/bridge-base8453-deploy.ps1 create mode 100644 infra/scripts/bridge-base8453-relay.ps1 create mode 100644 infra/scripts/bridge-evidence-export.ps1 create mode 100644 infra/scripts/flowchain-no-secret-scan.mjs create mode 100644 schemas/flowmemory/bridge-local-usage-proof.schema.json create mode 100644 schemas/flowmemory/bridge-pilot-evidence.schema.json create mode 100644 schemas/flowmemory/bridge-release-evidence.schema.json create mode 100644 schemas/flowmemory/bridge-runtime-credit-application-state.schema.json create mode 100644 schemas/flowmemory/bridge-runtime-credit-application.schema.json create mode 100644 schemas/flowmemory/bridge-withdrawal-authorization.schema.json create mode 100644 services/bridge-relayer/src/base8453-relay-mock-e2e.ts create mode 100644 services/bridge-relayer/src/base8453-relay-monitor.ts create mode 100644 services/bridge-relayer/src/base8453-tx-diagnostic.ts create mode 100644 services/bridge-relayer/src/bridge-live-readiness-check.ts create mode 100644 services/bridge-relayer/src/bridge-pilot-e2e.ts diff --git a/contracts/bridge/BaseBridgeLockbox.sol b/contracts/bridge/BaseBridgeLockbox.sol index a6b0c4b4..1d591066 100644 --- a/contracts/bridge/BaseBridgeLockbox.sol +++ b/contracts/bridge/BaseBridgeLockbox.sol @@ -16,6 +16,7 @@ contract BaseBridgeLockbox { uint256 perDepositCap; uint256 totalCap; uint256 totalLocked; + uint256 totalDeposited; } struct DepositRecord { @@ -32,10 +33,12 @@ contract BaseBridgeLockbox { address public constant NATIVE_TOKEN = address(0); bytes32 public constant BRIDGE_DEPOSIT_SCHEMA_ID = keccak256("flowmemory.bridge.deposit.v0"); bytes32 public constant BRIDGE_RELEASE_SCHEMA_ID = keccak256("flowmemory.bridge.release.v0"); + bytes32 public constant PILOT_MODE_TAG = keccak256("flowchain.base8453.owner-pilot.v0"); address public owner; address public releaseAuthority; bool public paused; + bool public emergencyStopped; uint256 public nextNonce = 1; mapping(address token => TokenConfig config) public tokenConfigs; @@ -48,6 +51,7 @@ contract BaseBridgeLockbox { error NotOwner(address caller); error NotReleaseAuthority(address caller); error Paused(); + error EmergencyStopped(); error ReentrantCall(); error ZeroOwner(); error ZeroReleaseAuthority(); @@ -68,16 +72,19 @@ contract BaseBridgeLockbox { event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); event ReleaseAuthoritySet(address indexed previousAuthority, address indexed newAuthority); event PausedSet(bool paused); + event EmergencyStopSet(bool stopped); event TokenConfigured(address indexed token, bool allowed, uint256 perDepositCap, uint256 totalCap); event BridgeDeposit( bytes32 indexed depositId, uint256 indexed sourceChainId, address indexed sender, + address lockbox, address token, uint256 amount, bytes32 flowchainRecipient, uint256 nonce, - bytes32 metadataHash + bytes32 metadataHash, + bytes32 pilotModeTag ); event BridgeRelease( bytes32 indexed releaseId, @@ -109,6 +116,13 @@ contract BaseBridgeLockbox { _; } + modifier whenNotEmergencyStopped() { + if (emergencyStopped) { + revert EmergencyStopped(); + } + _; + } + modifier nonReentrant() { if (_entered) { revert ReentrantCall(); @@ -158,13 +172,18 @@ contract BaseBridgeLockbox { emit PausedSet(value); } + function setEmergencyStopped(bool value) external onlyOwner { + emergencyStopped = value; + emit EmergencyStopSet(value); + } + function configureToken(address token, bool allowed, uint256 perDepositCap, uint256 totalCap) external onlyOwner { TokenConfig storage config = tokenConfigs[token]; if (allowed && perDepositCap == 0) { revert ZeroAmount(); } - if (allowed && totalCap != 0 && totalCap < config.totalLocked) { - revert TotalCapExceeded(token, config.totalLocked, totalCap); + if (allowed && totalCap != 0 && totalCap < config.totalDeposited) { + revert TotalCapExceeded(token, config.totalDeposited, totalCap); } config.allowed = allowed; @@ -177,6 +196,7 @@ contract BaseBridgeLockbox { external payable whenNotPaused + whenNotEmergencyStopped nonReentrant returns (bytes32 depositId) { @@ -186,6 +206,7 @@ contract BaseBridgeLockbox { function lockERC20(address token, uint256 amount, bytes32 flowchainRecipient, bytes32 metadataHash) external whenNotPaused + whenNotEmergencyStopped nonReentrant returns (bytes32 depositId) { @@ -201,6 +222,7 @@ contract BaseBridgeLockbox { function releaseNative(bytes32 depositId, address payable recipient, uint256 amount, bytes32 evidenceHash) external onlyReleaseAuthority + whenNotEmergencyStopped nonReentrant returns (bytes32 releaseId) { @@ -214,6 +236,7 @@ contract BaseBridgeLockbox { function releaseERC20(bytes32 depositId, address recipient, address token, uint256 amount, bytes32 evidenceHash) external onlyReleaseAuthority + whenNotEmergencyStopped nonReentrant returns (bytes32 releaseId) { @@ -257,9 +280,10 @@ contract BaseBridgeLockbox { revert PerDepositCapExceeded(token, amount, config.perDepositCap); } - uint256 nextTotal = config.totalLocked + amount; - if (config.totalCap != 0 && nextTotal > config.totalCap) { - revert TotalCapExceeded(token, nextTotal, config.totalCap); + uint256 nextTotalLocked = config.totalLocked + amount; + uint256 nextTotalDeposited = config.totalDeposited + amount; + if (config.totalCap != 0 && nextTotalDeposited > config.totalCap) { + revert TotalCapExceeded(token, nextTotalDeposited, config.totalCap); } uint256 nonce = nextNonce++; @@ -273,7 +297,8 @@ contract BaseBridgeLockbox { amount, flowchainRecipient, nonce, - metadataHash + metadataHash, + PILOT_MODE_TAG ) ); if (deposits[depositId]) { @@ -291,17 +316,20 @@ contract BaseBridgeLockbox { metadataHash: metadataHash, exists: true }); - config.totalLocked = nextTotal; + config.totalLocked = nextTotalLocked; + config.totalDeposited = nextTotalDeposited; emit BridgeDeposit({ depositId: depositId, sourceChainId: block.chainid, sender: sender, + lockbox: address(this), token: token, amount: amount, flowchainRecipient: flowchainRecipient, nonce: nonce, - metadataHash: metadataHash + metadataHash: metadataHash, + pilotModeTag: PILOT_MODE_TAG }); } diff --git a/docs/agent-runs/production-l1-bridge/ASSET_DECISION.md b/docs/agent-runs/production-l1-bridge/ASSET_DECISION.md new file mode 100644 index 00000000..56c82beb --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/ASSET_DECISION.md @@ -0,0 +1,28 @@ +# Asset Decision + +Status: implemented. + +The lockbox supports both native Base ETH and ERC20 custody paths. The owner pilot activates exactly one supported asset through configuration: + +- `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN=0x0000000000000000000000000000000000000000` means native ETH. +- `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN=` means the allowlisted ERC20. + +Why this decision fits the current architecture: + +- `BaseBridgeLockbox.lockNative` already handles native deposits with `msg.value`. +- `BaseBridgeLockbox.lockERC20` already handles ERC20 deposits with allowance and `transferFrom`. +- `configureToken` stores per-asset allowlist status, per-deposit cap, total pilot cap, and cumulative deposited amount. +- Release uses separate paths: `releaseNative` and `releaseERC20`. + +Refusal behavior: + +- Live observation refuses deposits for tokens not listed through `--supported-token` or `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN`. +- ERC20 deposits fail if the token is not allowlisted, amount is zero, allowance is missing, transfer fails, per-deposit cap is exceeded, or total cap is exceeded. +- Native deposits fail if native asset is not allowlisted, `msg.value` is zero, caps are exceeded, deposits are paused, or emergency stop is active. + +Proof: + +- `forge test --match-path tests/bridge/BaseBridgeLockbox.t.sol` passed. +- `forge test` passed. +- Mock pilot evidence used ERC20 token `0x3333333333333333333333333333333333333333`. +- Native ETH support remains contract-tested and live-gated by choosing the zero address as the supported token. diff --git a/docs/agent-runs/production-l1-bridge/CHECKLIST.md b/docs/agent-runs/production-l1-bridge/CHECKLIST.md new file mode 100644 index 00000000..71c948ff --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/CHECKLIST.md @@ -0,0 +1,86 @@ +# Capped Base 8453 Bridge Pilot Checklist + +## Context + +- [x] `AGENTS.md` +- [x] `docs/START_HERE.md` +- [x] `docs/FLOWMEMORY_HQ_CONTEXT.md` +- [x] `docs/CURRENT_STATE.md` +- [x] `docs/ROOTFLOW_V0.md` +- [x] `docs/FLOW_MEMORY_V0.md` +- [x] `docs/V0_LAUNCH_ACCEPTANCE.md` +- [x] Bridge POC docs and existing handoffs + +## Contract + +- [x] Base chain ID `8453` deployment configuration +- [x] Asset allowlist or documented native-only decision +- [x] Per-deposit cap +- [x] Total pilot cap +- [x] Deposit pause +- [x] Emergency stop +- [x] Owner authority +- [x] Release authority separation where possible +- [x] Replay protection for deposits and releases +- [x] Deterministic relayer event fields +- [x] Contract tests for happy path and refusal paths + +## Relayer + +- [x] `eth_chainId == 0x2105` live gate +- [x] Operator acknowledgement live gate +- [x] Required env validation without secret logging +- [x] Bounded block scan +- [x] Confirmation-depth checks +- [x] Deterministic observation ID +- [x] Deterministic credit ID +- [x] Evidence JSON export +- [x] Credit exactly once +- [x] Duplicate/replay handling +- [x] Withdrawal intent +- [x] Release evidence +- [x] Secret-free evidence bundle export + +## Ops + +- [x] Dry-run deploy command +- [x] Broadcast deploy command with acknowledgement +- [x] Observe command +- [x] Credit command +- [x] Replay check command +- [x] Withdrawal intent command +- [x] Release evidence command +- [x] Pause command +- [x] Resume command +- [x] Emergency stop command +- [x] Evidence export command +- [x] Mock pilot E2E command +- [x] Live readiness check command + +## Proof + +- [x] `CONTRACT_PROOF.md` +- [x] `RELAYER_PROOF.md` +- [x] `MOCK_PILOT_E2E_PROOF.md` +- [x] `REPLAY_PROOF.md` +- [x] `LIVE_READINESS_PROOF.md` +- [x] `OWNER_LIVE_TEST_COMMANDS.md` +- [x] `ASSET_DECISION.md` +- [x] `DEPLOYMENT_READINESS_PROOF.md` +- [x] `DEPOSIT_EVENT_PROOF.md` +- [x] `LIVE_OBSERVATION_GATE_PROOF.md` +- [x] `CREDIT_APPLICATION_PROOF.md` +- [x] `REAL_FUNDS_PILOT_RUNBOOK.md` +- [x] `WITHDRAW_RELEASE_PROOF.md` +- [x] `FULL_MOCK_PILOT_PROOF.md` +- [x] `HANDOFF.md` + +## Verification + +- [x] `forge test` +- [x] `npm test --prefix services/bridge-relayer` +- [x] `npm run bridge:local-credit:smoke` +- [x] `npm run bridge:pilot:mock:e2e` +- [x] `npm run bridge:pilot:live:check` +- [x] `npm run flowchain:real-value-pilot:e2e` +- [x] `git diff --check` diff --git a/docs/agent-runs/production-l1-bridge/CONTRACT_PROOF.md b/docs/agent-runs/production-l1-bridge/CONTRACT_PROOF.md new file mode 100644 index 00000000..3475a0e5 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/CONTRACT_PROOF.md @@ -0,0 +1,56 @@ +# Contract Proof + +Status: implemented and tested. + +Changed contract: + +- `contracts/bridge/BaseBridgeLockbox.sol` + +Core behavior proved: + +- Base deployment configuration is guarded for chain ID `8453` through deploy and observer scripts. +- Asset allowlist supports native ETH and ERC20 assets. +- Per-deposit cap is enforced for each configured asset. +- Total pilot cap is cumulative and does not reopen after release. +- Deposit pause blocks deposits. +- Emergency stop blocks deposits and releases. +- Owner controls token configuration, pause, emergency stop, and release authority updates. +- Release authority is separated from owner for release calls. +- Deposit accounting uses nonces and deterministic `depositId`. +- Release accounting uses `releaseId` replay protection. + +Relayer-facing event: + +```solidity +event BridgeDeposit( + bytes32 indexed depositId, + uint256 indexed sourceChainId, + address indexed sender, + address lockbox, + address token, + uint256 amount, + bytes32 flowchainRecipient, + uint256 nonce, + bytes32 metadataHash, + bytes32 pilotModeTag +); +``` + +Test proof: + +- `forge test --match-path tests/bridge/BaseBridgeLockbox.t.sol` passed with 16 tests. +- `forge test` passed with 85 tests. + +Covered refusal branches: + +- zero amount +- unsupported token +- exceeded per-deposit cap +- exceeded total pilot cap +- paused deposits +- emergency stop +- wrong release authority +- duplicate release +- mismatched release token +- unavailable release amount +- zero evidence hash diff --git a/docs/agent-runs/production-l1-bridge/CREDIT_APPLICATION_PROOF.md b/docs/agent-runs/production-l1-bridge/CREDIT_APPLICATION_PROOF.md new file mode 100644 index 00000000..289215fe --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/CREDIT_APPLICATION_PROOF.md @@ -0,0 +1,44 @@ +# Credit Application Proof + +Status: implemented and tested in local pilot state. + +Credit path: + +1. Observe Base deposit evidence. +2. Derive deterministic observation ID. +3. Derive deterministic replay key. +4. Derive deterministic credit ID. +5. Verify source chain is Base `8453`. +6. Verify source lockbox is approved. +7. Verify token is configured. +8. Verify amount is within the configured caps. +9. Apply local FlowChain credit state exactly once. +10. Write runtime handoff and application state. + +Exactly-once state: + +- State file: `services/bridge-relayer/out/real-value-pilot-e2e/bridge-credit-application-state.json` +- Replay key: `0xea93b7d168d2f1f6c4be4f95ba4d85aa2d07fc4298a720d180000a19d98481f0` +- First application: `applied` +- Same-event replay: `idempotent_replay` +- Duplicate fixture replay: rejected with `duplicate_replay_key` + +Mock E2E deterministic IDs: + +- observation ID: `0x01d76831a495a9869e1f880ae44fdf6b382bc1a2c0fe593e5536a9538989b73b` +- credit ID: `0x6f9e131efd014f742a589e62393bce237d9daee3ef7cd4ef9c0b7f5e95d10dc6` + +Local usage artifact: + +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-local-usage-proof.json` +- transfer ID: `0x7b0654d6bf64c91e835eebafd17cc1c6e55aa7b555d02f533a59170cab46be5b` +- credited wallet after credit: `20000000` +- credited wallet after prepared transfer: `10000000` +- second wallet after prepared transfer: `10000000` + +Commands: + +```powershell +npm run bridge:local-credit:smoke +npm run bridge:pilot:mock:e2e +``` diff --git a/docs/agent-runs/production-l1-bridge/DEPLOYMENT_READINESS_PROOF.md b/docs/agent-runs/production-l1-bridge/DEPLOYMENT_READINESS_PROOF.md new file mode 100644 index 00000000..e6de23db --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/DEPLOYMENT_READINESS_PROOF.md @@ -0,0 +1,47 @@ +# Deployment Readiness Proof + +Status: dry-run path implemented; broadcast path gated. + +Script: + +- `infra/scripts/bridge-base8453-deploy.ps1` + +Commands: + +```powershell +npm run bridge:deploy:dry-run +npm run bridge:deploy:base8453 -- -AcknowledgeBroadcast +``` + +Required env names: + +- `FLOWCHAIN_BASE8453_RPC_URL` +- `FLOWCHAIN_BASE8453_DEPLOYER_PRIVATE_KEY` +- `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN` +- `FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI` +- `FLOWCHAIN_PILOT_TOTAL_CAP_WEI` +- `FLOWCHAIN_PILOT_OPERATOR_ACK` + +Required acknowledgement: + +```text +I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT +``` + +Deployment checks: + +- Verifies `eth_chainId == 0x2105` before broadcast. +- Derives owner from `FLOWCHAIN_BASE8453_DEPLOYER_PRIVATE_KEY` locally through `cast wallet address`. +- Uses the same owner as initial release authority unless the script is extended later with a separate local owner-provided release key. +- Maps native ETH by setting `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN` to zero address. +- Maps ERC20 by setting `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN` to the token address. +- Writes a readiness artifact without secrets. + +Current proof artifact: + +- `services/bridge-relayer/out/base8453-deploy-readiness.json` + +Current status: + +- Dry-run completed as `missing-env-safe` because owner live env values were not present. +- No live deploy or contract verification was attempted without owner env. diff --git a/docs/agent-runs/production-l1-bridge/DEPOSIT_EVENT_PROOF.md b/docs/agent-runs/production-l1-bridge/DEPOSIT_EVENT_PROOF.md new file mode 100644 index 00000000..f448740a --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/DEPOSIT_EVENT_PROOF.md @@ -0,0 +1,47 @@ +# Deposit Event Proof + +Status: implemented. + +The lockbox emits deterministic deposit event fields for the relayer: + +- `sourceChainId`: emitted as `block.chainid` +- `lockbox`: emitted as the lockbox address +- `token`: zero address for native ETH or ERC20 token address +- `sender`: Base depositor +- `flowchainRecipient`: local/private FlowChain recipient bytes32 +- `amount`: locked amount +- `nonce`: per-lockbox deposit nonce +- `depositId`: deterministic event key +- `metadataHash`: caller-supplied metadata commitment +- `pilotModeTag`: `keccak256("flowchain.base8453.owner-pilot.v0")` + +The relayer also records receipt-derived fields that contracts cannot know during execution: + +- `transactionHash` +- `logIndex` +- `blockNumber` +- `blockHash` when available +- `confirmations` + +Deterministic deposit ID inputs: + +```text +BRIDGE_DEPOSIT_SCHEMA_ID +block.chainid +lockbox address +sender +token +amount +flowchain recipient +nonce +metadata hash +pilot mode tag +``` + +Parser proof: + +- Relayer tests decode the extended event signature: + `BridgeDeposit(bytes32,uint256,address,address,address,uint256,bytes32,uint256,bytes32,bytes32)`. +- Legacy test fixtures remain accepted for prior POC lanes. +- The parser verifies the event lockbox data field matches the emitting log address for the extended event. +- The parser verifies the pilot mode tag for the extended event. diff --git a/docs/agent-runs/production-l1-bridge/EXPERIMENTS.md b/docs/agent-runs/production-l1-bridge/EXPERIMENTS.md new file mode 100644 index 00000000..0be8ddab --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/EXPERIMENTS.md @@ -0,0 +1,18 @@ +# Capped Base 8453 Bridge Pilot Experiments + +This file records runnable checks, commands, outcomes, and evidence paths. + +| Time | Command | Outcome | Notes | +| --- | --- | --- | --- | +| 2026-05-14 | Initial setup | Passed | Tracking files created before implementation edits. | +| 2026-05-14 | `forge test --match-path tests/bridge/BaseBridgeLockbox.t.sol` | Passed | 16 lockbox tests. | +| 2026-05-14 | `forge test` | Passed | 85 Foundry tests. | +| 2026-05-14 | `npm test --prefix services/bridge-relayer` | Passed | 15 relayer tests. | +| 2026-05-14 | `npm run bridge:local-credit:smoke` | Passed | Regenerated local runtime handoff and withdrawal intent. | +| 2026-05-14 | `npm run bridge:pilot:mock:e2e` | Passed | Wrote observation, credit, replay, withdrawal, release, and local usage artifacts. | +| 2026-05-14 | `npm run bridge:pilot:live:check` | Passed | Self-test proved fail-closed live gates without live env values. | +| 2026-05-14 | `npm run bridge:deploy:dry-run` | Passed | Wrote missing-env-safe deploy readiness report. | +| 2026-05-14 | `npm run bridge:evidence:export` | Passed | Wrote secret-free evidence export report and zip; latest SHA256 `2D0C1CB36915E98B50EC2473B5CBA5166F49CC0777DB20526CC11BE17ADC92BE`. | +| 2026-05-14 | `npm run flowchain:real-value-pilot:e2e` | Passed | Full owner pilot gate passed with bridge, runtime, wallet, dashboard/control, ops, and baseline checks. | +| 2026-05-14 | `node infra/scripts/check-unsafe-claims.mjs` | Passed | Docs/contracts contain no unsafe launch claims. | +| 2026-05-14 | `git diff --check` | Passed | No whitespace errors. | diff --git a/docs/agent-runs/production-l1-bridge/FULL_MOCK_PILOT_PROOF.md b/docs/agent-runs/production-l1-bridge/FULL_MOCK_PILOT_PROOF.md new file mode 100644 index 00000000..0da05ff9 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/FULL_MOCK_PILOT_PROOF.md @@ -0,0 +1,46 @@ +# Full Mock Pilot Proof + +Status: passed. + +Bridge command: + +```powershell +npm run bridge:pilot:mock:e2e +``` + +Full owner pilot gate: + +```powershell +npm run flowchain:real-value-pilot:e2e +``` + +Full gate report: + +- `devnet/local/real-value-pilot/flowchain-real-value-pilot-e2e-report.json` + +Full gate checks passed: + +- `npm run flowchain:product-e2e` +- `npm run flowchain:l1-e2e` +- `npm run flowchain:real-value-pilot:contracts` +- `npm run flowchain:real-value-pilot:bridge` +- `npm run flowchain:real-value-pilot:runtime` +- `npm run flowchain:real-value-pilot:wallet` +- `npm run flowchain:real-value-pilot:control-dashboard` +- `npm run flowchain:real-value-pilot:ops` + +Bridge-specific mock path: + +- Uses `fixtures/bridge/base8453-pilot-mock-deposit.json`. +- Produces evidence without RPC or keys. +- Applies local credit once. +- Prepares local transfer handoff to a second wallet. +- Links product/DEX coverage to the existing product E2E command. +- Produces withdrawal intent and release evidence. +- Proves duplicate credit rejection. + +Boundaries: + +- No live Base transaction was sent. +- No owner key or RPC URL was committed. +- This remains a capped owner pilot path, not a public deposit system. diff --git a/docs/agent-runs/production-l1-bridge/HANDOFF.md b/docs/agent-runs/production-l1-bridge/HANDOFF.md new file mode 100644 index 00000000..f1e10497 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/HANDOFF.md @@ -0,0 +1,107 @@ +# Handoff + +Status: capped Base `8453` owner pilot bridge path implemented, mock-proved, and live-gated. + +## Contract Events + +- `BridgeDeposit(bytes32 indexed depositId, uint256 indexed sourceChainId, address indexed sender, address lockbox, address token, uint256 amount, bytes32 flowchainRecipient, uint256 nonce, bytes32 metadataHash, bytes32 pilotModeTag)` +- `BridgeRelease(bytes32 indexed releaseId, bytes32 indexed depositId, address indexed recipient, address token, uint256 amount, bytes32 evidenceHash)` +- `EmergencyStopSet(bool stopped)` + +## Schema Paths + +- `schemas/flowmemory/bridge-deposit.schema.json` +- `schemas/flowmemory/bridge-observation.schema.json` +- `schemas/flowmemory/bridge-credit.schema.json` +- `schemas/flowmemory/bridge-runtime-credit-application.schema.json` +- `schemas/flowmemory/bridge-runtime-credit-application-state.schema.json` +- `schemas/flowmemory/bridge-pilot-evidence.schema.json` +- `schemas/flowmemory/bridge-withdrawal-intent.schema.json` +- `schemas/flowmemory/bridge-withdrawal-authorization.schema.json` +- `schemas/flowmemory/bridge-release-evidence.schema.json` +- `schemas/flowmemory/bridge-local-usage-proof.schema.json` +- `schemas/flowmemory/bridge-runtime-handoff.schema.json` + +## Command Names + +- `bridge:deploy:dry-run` +- `bridge:deploy:base8453` +- `bridge:observe:mock` +- `bridge:observe:base8453` +- `bridge:credit:local` +- `bridge:credit:replay-check` +- `bridge:withdraw:intent` +- `bridge:release:evidence` +- `bridge:pause` +- `bridge:resume` +- `bridge:emergency-stop` +- `bridge:evidence:export` +- `bridge:pilot:mock:e2e` +- `bridge:pilot:live:check` +- `flowchain:real-value-pilot:e2e` + +## Env Names + +- `FLOWCHAIN_BASE8453_RPC_URL` +- `FLOWCHAIN_BASE8453_DEPLOYER_PRIVATE_KEY` +- `FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS` +- `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN` +- `FLOWCHAIN_BASE8453_FROM_BLOCK` +- `FLOWCHAIN_BASE8453_TO_BLOCK` +- `FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI` +- `FLOWCHAIN_PILOT_TOTAL_CAP_WEI` +- `FLOWCHAIN_PILOT_CONFIRMATIONS` +- `FLOWCHAIN_PILOT_OPERATOR_ACK` +- optional: `FLOWCHAIN_PILOT_MAX_USD` + +## Evidence Paths + +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-real-value-pilot-e2e-report.json` +- `services/bridge-relayer/out/base8453-live-readiness-check.json` +- `services/bridge-relayer/out/base8453-deploy-readiness.json` +- `services/bridge-relayer/out/base8453-bridge-evidence-export-report.json` +- `fixtures/bridge/local-runtime-bridge-handoff.json` +- `devnet/local/real-value-pilot/flowchain-real-value-pilot-e2e-report.json` + +## RPC Fields + +Bridge handoff objects expose: + +- observations +- credits +- runtimeApplications +- withdrawalIntents +- pilotEvidence +- releaseEvidences +- replayProtection +- explorerSections +- workbenchTimeline + +## Dashboard Fields + +The bridge handoff provides dashboard/control-plane fields through: + +- `explorerSections` +- `workbenchTimeline` +- `summary` +- `limitations` +- `nextCommands` +- `replayProtection` +- `runtimeApplications` + +Dashboard implementation was not edited in this task. + +## Remaining Blocker + +Live Base deploy, deposit, observe, and release cannot be executed without owner-provided local env values: + +- RPC URL +- deployer private key +- supported asset +- caps +- confirmation depth +- lockbox address after deploy +- bounded block range after deposit +- operator acknowledgement + +The code fails closed when these values are missing or unsafe. diff --git a/docs/agent-runs/production-l1-bridge/LIVE_LOW_LATENCY_RELAY.md b/docs/agent-runs/production-l1-bridge/LIVE_LOW_LATENCY_RELAY.md new file mode 100644 index 00000000..74ea5740 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/LIVE_LOW_LATENCY_RELAY.md @@ -0,0 +1,117 @@ +# Base 8453 Live Low-Latency Relay + +Status: code implemented and mock-proved; live spendability remains externally blocked until owner live env and a real eligible deposit are available. + +## Scope + +This run added the Base `8453` relay mode for the live lockbox: + +- lockbox: `0xe731Bc6b117d92deDCA40a7ccAec11d16205026a` +- deposit selector: `0x1326d1ec` / `lockNative(bytes32,bytes32)` +- chain: Base `8453` / `0x2105` +- default confirmation eligibility: `12` +- default monitor poll interval: `5000` ms +- release broadcast: `false` + +The relay does not send Base release transactions. Release evidence can still be generated by separate existing evidence commands, but broadcast remains outside this relay. + +## Implemented Behavior + +- `services/bridge-relayer/src/base8453-relay-monitor.ts` + - runs as a durable monitor or one-shot relay cycle; + - computes a rolling confirmed scan window from latest Base block minus confirmations; + - enforces a configured maximum scan width and bounded recovery window; + - persists `checkpoint.json`; + - writes a status file that separates no deposits, pending confirmations, queued credits, node-spendable credits, duplicate/idempotent replay, and invalid diagnostic states; + - writes a full handoff with all observed deposits in the window; + - submits only newly applied relay credits to the local FlowChain node intake path. +- `services/bridge-relayer/src/base8453-tx-diagnostic.ts` + - checks receipt status; + - checks recipient equals approved lockbox; + - checks selector equals `0x1326d1ec`; + - checks `BridgeDeposit` exists; + - classifies direct ETH, revert, wrong contract, missing event, wrong chain, and cap failures without printing RPC values. +- `infra/scripts/bridge-base8453-relay.ps1` + - operator wrapper for the low-latency relay; + - defaults to the live lockbox and native ETH zero-address asset; + - requires caps and explicit pilot acknowledgement; + - does not require or print private keys. + +## Commands + +Mock proof: + +```powershell +npm run flowchain:bridge:mock:e2e +``` + +Live readiness self-test or live check when env is loaded: + +```powershell +npm run flowchain:bridge:live:check +``` + +One relay cycle: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base8453-relay.ps1 -OperatorAck -Once +``` + +Continuous relay: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base8453-relay.ps1 -OperatorAck +``` + +Transaction diagnostic: + +```powershell +npm run bridge:tx:diagnose -- --tx-hash --acknowledge-pilot +``` + +## Report Artifacts + +The mock low-latency E2E wrote: + +- `devnet/local/live-base8453-relay/mock-e2e-report.json` +- `devnet/local/live-base8453-relay/relay-report.json` +- `devnet/local/live-base8453-relay/status.json` +- `devnet/local/live-base8453-relay/checkpoint.json` +- `devnet/local/live-base8453-relay/bridge-runtime-handoff.json` +- `devnet/local/live-base8453-relay/mock-node-state.json` + +Mock E2E evidence: + +- latest Base block: `112` +- scanFrom/scanTo: `0` / `100` +- confirmation depth: `12` +- tx hash: `0x8453000000000000000000000000000000000000000000000000000000000002` +- log index: `0` +- depositId: `0x8453000000000000000000000000000000000000000000000000000000000001` +- first run applied credits: `1` +- replay run idempotent credits: `1` +- handoff path: `devnet/local/live-base8453-relay/bridge-runtime-handoff.json` +- running L1 ingest mode in mock proof: direct local node state +- first spendable timestamp: recorded in `mock-e2e-report.json` + +The final status after replay intentionally remains `READY` with `previousReadyPreserved: true`; the duplicate read is reported as `duplicate_idempotent_replay`, not as a new failure. + +## Required Checks + +Completed in this run: + +- `npm test --prefix services/bridge-relayer` +- `npm run flowchain:bridge:mock:e2e` +- `npm run flowchain:bridge:live:check` in self-test mode because live env was not loaded +- `npm run flowchain:no-secret:scan` +- `git diff --check` + +## Live Blocker + +No owner live env or new real deposit was available in this session. Because of that, this branch must not claim the bridge is complete or broadly production-ready. The remaining external proof is: + +1. load owner env locally without committing values; +2. run the continuous relay against Base `8453`; +3. make or observe a real `lockNative(bytes32,bytes32)` deposit to the approved lockbox; +4. wait until the deposit is 12-confirmation eligible; +5. prove the relay status reaches `READY` because the credit is spendable in the running FlowChain node state within 60 seconds after eligibility. diff --git a/docs/agent-runs/production-l1-bridge/LIVE_OBSERVATION_GATE_PROOF.md b/docs/agent-runs/production-l1-bridge/LIVE_OBSERVATION_GATE_PROOF.md new file mode 100644 index 00000000..cc68a1b3 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/LIVE_OBSERVATION_GATE_PROOF.md @@ -0,0 +1,49 @@ +# Live Observation Gate Proof + +Status: implemented. + +Command: + +```powershell +npm run bridge:pilot:live:check +``` + +Self-test artifact: + +- `services/bridge-relayer/out/base8453-live-readiness-check.json` + +Checks proved by the self-test: + +- Missing env names are listed without printing values. +- Missing operator acknowledgement is rejected. +- Missing confirmation depth is rejected. +- Missing supported token is rejected. +- Broad block scan is rejected. +- Unapproved lockbox is rejected. +- Wrong chain ID is rejected before log scan. + +Live observation behavior: + +- `eth_chainId` must return `0x2105`. +- `eth_blockNumber` is read to enforce confirmation depth. +- `eth_getLogs` is called only after chain, acknowledgement, lockbox, block range, and cap guardrails pass. +- Deposits above `FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI` are rejected. +- Total observed pilot amount above `FLOWCHAIN_PILOT_TOTAL_CAP_WEI` is rejected. +- Evidence records confirmation count. +- If a log is not returned by the confirmed bounded scan, no observation and no credit are produced. + +Required observe env names: + +- `FLOWCHAIN_BASE8453_RPC_URL` +- `FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS` +- `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN` +- `FLOWCHAIN_BASE8453_FROM_BLOCK` +- `FLOWCHAIN_BASE8453_TO_BLOCK` +- `FLOWCHAIN_PILOT_CONFIRMATIONS` +- `FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI` +- `FLOWCHAIN_PILOT_TOTAL_CAP_WEI` +- `FLOWCHAIN_PILOT_OPERATOR_ACK` + +Optional live observe env: + +- `FLOWCHAIN_PILOT_MAX_USD` diff --git a/docs/agent-runs/production-l1-bridge/LIVE_READINESS_PROOF.md b/docs/agent-runs/production-l1-bridge/LIVE_READINESS_PROOF.md new file mode 100644 index 00000000..eb6cafb0 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/LIVE_READINESS_PROOF.md @@ -0,0 +1,40 @@ +# Live Readiness Proof + +Status: fail-closed behavior proved without live secrets. + +Command: + +```powershell +npm run bridge:pilot:live:check +``` + +Report: + +- `services/bridge-relayer/out/base8453-live-readiness-check.json` + +Report status: + +- `status`: `passed` +- `liveMode`: `false` +- `noSecrets`: `true` + +The readiness check proved: + +- Missing env values are reported by env name only. +- Missing `FLOWCHAIN_PILOT_OPERATOR_ACK` is rejected. +- Missing `FLOWCHAIN_PILOT_CONFIRMATIONS` is rejected. +- Missing `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN` is rejected. +- Broad block scans are rejected. +- Unapproved lockbox addresses are rejected. +- Wrong chain IDs are rejected before log scanning. + +Owner acknowledgement value: + +```text +I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT +``` + +Current live blocker: + +- Owner live RPC URL, deployer key, lockbox address, supported token, caps, confirmation depth, bounded block range, and acknowledgement were not present in the worktree. +- Therefore no live Base transaction was executed. diff --git a/docs/agent-runs/production-l1-bridge/MOCK_PILOT_E2E_PROOF.md b/docs/agent-runs/production-l1-bridge/MOCK_PILOT_E2E_PROOF.md new file mode 100644 index 00000000..74f51bc4 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/MOCK_PILOT_E2E_PROOF.md @@ -0,0 +1,49 @@ +# Mock Pilot E2E Proof + +Status: passed without live RPC, live keys, or broadcast. + +Command: + +```powershell +npm run bridge:pilot:mock:e2e +``` + +Report: + +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-real-value-pilot-e2e-report.json` + +Artifacts: + +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-observation.json` +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-credit.json` +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-pilot-evidence.json` +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-runtime-handoff.json` +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-local-usage-proof.json` +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-withdrawal-intent.json` +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-withdrawal-authorization.json` +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-release-evidence.json` +- `services/bridge-relayer/out/real-value-pilot-e2e/bridge-replay-handoff.json` + +Covered flow: + +- Base `8453` mock deposit observed. +- Deterministic observation ID derived. +- Deterministic credit ID derived. +- Local credit applied once. +- Same-event replay returned idempotent status without double credit. +- Duplicate deposit replay was rejected. +- Local transfer handoff prepared for a second wallet. +- Product/DEX local gate linked through `npm run flowchain:product-e2e`. +- Withdrawal intent generated with Base recipient, asset, and amount. +- Companion withdrawal authorization generated with nonce, local chain ID, signed payload hash, and deterministic test signature. +- Release evidence generated for `releaseERC20`. +- Wrong-chain and unapproved-lockbox negative checks passed. + +Report values: + +- observation ID: `0x01d76831a495a9869e1f880ae44fdf6b382bc1a2c0fe593e5536a9538989b73b` +- credit ID: `0x6f9e131efd014f742a589e62393bce237d9daee3ef7cd4ef9c0b7f5e95d10dc6` +- withdrawal intent ID: `0x1ed8e1c5b59f306a3892e7a6befbbeeb4417cd6656d2ab51457ac5ab7ec16b0f` +- withdrawal authorization ID: `0x6dde86c11bc71f6d385e0dc2ba0d7874c7fb7ff2bff56d864014b30cb0b2c057` +- release evidence ID: `0x6dfe4e3d9b05aa930b164fffb69a3068a0b8609417d62233dba0fcaa47350685` +- local transfer ID: `0x7b0654d6bf64c91e835eebafd17cc1c6e55aa7b555d02f533a59170cab46be5b` diff --git a/docs/agent-runs/production-l1-bridge/NOTES.md b/docs/agent-runs/production-l1-bridge/NOTES.md new file mode 100644 index 00000000..2d765285 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/NOTES.md @@ -0,0 +1,15 @@ +# Capped Base 8453 Bridge Pilot Notes + +Initial assumptions: + +- Live Base credentials and owner keys are not available in the repository. +- The mock pilot path must fully run without live RPC or keys. +- Live Base mode must fail closed until the owner supplies local env values and explicit acknowledgement. +- Evidence and logs must name required env variables but never print env values. + +Resolved decisions: + +- The current lockbox supports both native ETH and ERC20 custody paths. The owner pilot activates exactly one configured asset through `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN`; zero address means native ETH, any nonzero address means that ERC20. +- Local FlowChain credit state is represented by bridge-relayer JSON state and handoff artifacts under `services/bridge-relayer/out/` and `fixtures/bridge/local-runtime-bridge-handoff.json`. +- The bridge relayer writes a deterministic local transfer handoff artifact. Product/DEX usage remains covered by the existing `npm run flowchain:product-e2e` gate because this task does not edit runtime, dashboard, or product execution modules. +- Live Base transaction execution remains blocked without owner-supplied RPC URL, deployer private key, lockbox address, supported asset, caps, confirmations, bounded block range, and acknowledgement. diff --git a/docs/agent-runs/production-l1-bridge/OWNER_LIVE_TEST_COMMANDS.md b/docs/agent-runs/production-l1-bridge/OWNER_LIVE_TEST_COMMANDS.md new file mode 100644 index 00000000..75a9d949 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/OWNER_LIVE_TEST_COMMANDS.md @@ -0,0 +1,96 @@ +# Owner Live Test Commands + +These commands are separated into dry-run and live-gated steps. Do not put env values in committed files. + +## Required Env Names + +```powershell +$env:FLOWCHAIN_BASE8453_RPC_URL = "" +$env:FLOWCHAIN_BASE8453_DEPLOYER_PRIVATE_KEY = "" +$env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS = "" +$env:FLOWCHAIN_BASE8453_SUPPORTED_TOKEN = "" +$env:FLOWCHAIN_BASE8453_FROM_BLOCK = "" +$env:FLOWCHAIN_BASE8453_TO_BLOCK = "" +$env:FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI = "" +$env:FLOWCHAIN_PILOT_TOTAL_CAP_WEI = "" +$env:FLOWCHAIN_PILOT_CONFIRMATIONS = "" +$env:FLOWCHAIN_PILOT_OPERATOR_ACK = "I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT" +``` + +## Dry Run + +```powershell +npm run bridge:deploy:dry-run +npm run bridge:pilot:live:check +``` + +## Broadcast Deploy + +```powershell +npm run bridge:deploy:base8453 -- -AcknowledgeBroadcast +``` + +The deploy script checks `eth_chainId == 0x2105` and does not print the RPC URL or private key. + +## Deposit + +Use the owner wallet to call exactly one of: + +- native ETH: `lockNative(bytes32 flowchainRecipient, bytes32 metadataHash)` with tiny `msg.value` +- ERC20: approve the lockbox, then call `lockERC20(address token, uint256 amount, bytes32 flowchainRecipient, bytes32 metadataHash)` + +Use only the configured supported asset and keep the amount below `FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI`. + +## Observe And Credit + +```powershell +npm run bridge:observe:base8453 -- -ApplyCredit -WithdrawalIntent +``` + +Equivalent direct command: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-mainnet-pilot-observe.ps1 -OperatorAck -ApplyCredit -WithdrawalIntent +``` + +## Replay Check + +```powershell +npm run bridge:credit:replay-check +``` + +## Local Usage Gates + +```powershell +npm run bridge:local-credit:smoke +npm run flowchain:product-e2e +``` + +The bridge E2E writes `bridge-local-usage-proof.json`; the existing product gate covers local product/DEX behavior. + +## Withdrawal And Release Evidence + +```powershell +npm run bridge:withdraw:intent +npm run bridge:release:evidence +``` + +The mock E2E writes the canonical withdrawal intent plus `bridge-withdrawal-authorization.json`, which carries nonce, local chain ID, signed payload hash, and deterministic test signature fields. + +## Operator Controls + +```powershell +npm run bridge:pause -- -Execute +npm run bridge:resume -- -Execute +npm run bridge:emergency-stop -- -Execute +``` + +## Evidence Export + +```powershell +npm run bridge:evidence:export +``` + +Export report: + +- `services/bridge-relayer/out/base8453-bridge-evidence-export-report.json` diff --git a/docs/agent-runs/production-l1-bridge/PLAN.md b/docs/agent-runs/production-l1-bridge/PLAN.md new file mode 100644 index 00000000..1c49bbd7 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/PLAN.md @@ -0,0 +1,31 @@ +# Capped Base 8453 Bridge Pilot Plan + +Status: implemented and verified in mock/gated-live modes. + +Scope: capped Base public network chain ID `8453` real-value pilot bridge path into local/private FlowChain pilot state. + +Safety floor: + +- No committed keys, RPC URLs, webhook URLs, seed phrases, or env files with secrets. +- Base live observation must verify `eth_chainId == 0x2105`. +- Live mode must require explicit operator acknowledgement. +- Observation must use a bounded block range and configured confirmation depth. +- Contract custody must remain capped, allowlisted, pausable, emergency-stoppable, and replay-protected. +- Duplicate deposit or release evidence must not apply value twice. + +Phases: + +1. [x] Read required repository, bridge, and handoff context. +2. [x] Audit existing contract, test, relayer, schema, fixture, script, and package surfaces. +3. [x] Fill contract gaps for caps, allowlist, pause, emergency stop, authorities, replay protection, and deterministic events. +4. [x] Add and repair contract tests for all movement and blocking branches. +5. [x] Build relayer observation, evidence, deterministic IDs, credit, withdrawal intent, release evidence, replay, and export paths. +6. [x] Add guarded Base `8453` deploy, observe, pause, resume, emergency, live readiness, and evidence commands. +7. [x] Add mock pilot E2E that runs without live credentials. +8. [x] Run required checks and write proof artifacts, live command docs, runbook, and handoff. + +Live Base status: + +- No owner RPC URL, deployer key, or live lockbox address was present in the worktree. +- Live actions are therefore safely blocked until the owner supplies local env values and the required acknowledgement. +- Mock and readiness paths prove the bridge behavior without committing secrets or broadcasting a live transaction. diff --git a/docs/agent-runs/production-l1-bridge/REAL_FUNDS_PILOT_RUNBOOK.md b/docs/agent-runs/production-l1-bridge/REAL_FUNDS_PILOT_RUNBOOK.md new file mode 100644 index 00000000..942ef79b --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/REAL_FUNDS_PILOT_RUNBOOK.md @@ -0,0 +1,133 @@ +# Real Funds Pilot Runbook + +Scope: capped owner test from Base chain ID `8453` into local/private FlowChain pilot state. + +## Safety Rules + +- Use a tiny amount only. +- Configure one supported asset only. +- Keep per-deposit and total caps low. +- Use a bounded block range. +- Require confirmation depth. +- Require operator acknowledgement. +- Never commit env files, RPC URLs, keys, or wallet secrets. + +## 1. Set Local Env + +Set these locally only: + +```powershell +$env:FLOWCHAIN_BASE8453_RPC_URL = "" +$env:FLOWCHAIN_BASE8453_DEPLOYER_PRIVATE_KEY = "" +$env:FLOWCHAIN_BASE8453_SUPPORTED_TOKEN = "" +$env:FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI = "" +$env:FLOWCHAIN_PILOT_TOTAL_CAP_WEI = "" +$env:FLOWCHAIN_PILOT_CONFIRMATIONS = "" +$env:FLOWCHAIN_PILOT_OPERATOR_ACK = "I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT" +``` + +## 2. Dry-Run Deploy + +```powershell +npm run bridge:deploy:dry-run +``` + +## 3. Broadcast Deploy + +```powershell +npm run bridge:deploy:base8453 -- -AcknowledgeBroadcast +``` + +Record the deployed lockbox locally: + +```powershell +$env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS = "" +``` + +## 4. Deposit From Owner Wallet + +Native ETH path: + +```text +lockNative(flowchainRecipient, metadataHash) with tiny msg.value +``` + +ERC20 path: + +```text +approve(lockbox, tinyAmount) +lockERC20(token, tinyAmount, flowchainRecipient, metadataHash) +``` + +## 5. Observe Confirmed Deposit + +Set a narrow range around the deposit block: + +```powershell +$env:FLOWCHAIN_BASE8453_FROM_BLOCK = "" +$env:FLOWCHAIN_BASE8453_TO_BLOCK = "" +npm run bridge:observe:base8453 -- -ApplyCredit -WithdrawalIntent +``` + +## 6. Local Credit And Usage + +```powershell +npm run bridge:local-credit:smoke +npm run flowchain:product-e2e +``` + +Bridge-local transfer evidence is written by: + +```powershell +npm run bridge:pilot:mock:e2e +``` + +## 7. Withdrawal Intent + +```powershell +npm run bridge:withdraw:intent +``` + +The canonical intent includes Base recipient, asset, and amount. The mock E2E also writes `bridge-withdrawal-authorization.json` with nonce, local chain ID, signed payload hash, and deterministic test signature fields. + +## 8. Release Evidence + +```powershell +npm run bridge:release:evidence +``` + +Review the evidence before any operator release transaction. + +## 9. Controls + +Pause deposits: + +```powershell +npm run bridge:pause -- -Execute +``` + +Resume deposits: + +```powershell +npm run bridge:resume -- -Execute +``` + +Emergency stop: + +```powershell +npm run bridge:emergency-stop -- -Execute +``` + +## 10. Export Evidence + +```powershell +npm run bridge:evidence:export +``` + +Expected report: + +- `services/bridge-relayer/out/base8453-bridge-evidence-export-report.json` + +Current blocker for live execution: + +- Owner live env values were not present in this worktree, so live deploy/deposit/observe was not executed here. diff --git a/docs/agent-runs/production-l1-bridge/RELAYER_PROOF.md b/docs/agent-runs/production-l1-bridge/RELAYER_PROOF.md new file mode 100644 index 00000000..e5008364 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/RELAYER_PROOF.md @@ -0,0 +1,51 @@ +# Relayer Proof + +Status: implemented and tested. + +Main implementation: + +- `services/bridge-relayer/src/observe-base-lockbox.ts` +- `services/bridge-relayer/src/bridge-pilot-e2e.ts` +- `services/bridge-relayer/src/bridge-live-readiness-check.ts` + +Live Base gates: + +- Calls `eth_chainId` before log reads. +- Requires `0x2105` for Base chain ID `8453`. +- Requires explicit operator acknowledgement. +- Requires explicit lockbox address. +- Requires explicit supported token. +- Requires confirmation depth. +- Requires bounded `fromBlock` and `toBlock`. +- Rejects scans wider than 5000 blocks. +- Rejects unapproved lockbox. +- Rejects unsupported token. +- Rejects missing local recipient. +- Rejects deposits over configured cap. + +Deterministic IDs: + +- observation ID: derived from observed deposit source fields +- replay key: derived from source chain, lockbox, tx hash, log index, and deposit ID +- credit ID: derived from observation ID and deposit fields +- pilot evidence ID: derived from observation, credit, and guardrails +- withdrawal intent ID: derived from credit, asset, amount, FlowChain account, and Base recipient +- release evidence ID: derived from withdrawal intent and release evidence hash +- withdrawal authorization ID: derived from withdrawal intent, signed payload hash, and deterministic test signature + +Evidence outputs: + +- `bridge-observation.json` +- `bridge-credit.json` +- `bridge-pilot-evidence.json` +- `bridge-runtime-handoff.json` +- `bridge-withdrawal-intent.json` +- `bridge-withdrawal-authorization.json` +- `bridge-release-evidence.json` +- `bridge-local-usage-proof.json` + +Test proof: + +- `npm test --prefix services/bridge-relayer` passed with 15 tests. +- `npm run bridge:pilot:mock:e2e` passed. +- `npm run bridge:pilot:live:check` passed. diff --git a/docs/agent-runs/production-l1-bridge/REPLAY_PROOF.md b/docs/agent-runs/production-l1-bridge/REPLAY_PROOF.md new file mode 100644 index 00000000..093e063a --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/REPLAY_PROOF.md @@ -0,0 +1,35 @@ +# Replay Proof + +Status: implemented and tested. + +Deposit replay protection: + +- Contract deposit nonces are included in `depositId`. +- Relayer replay keys include source chain, lockbox, tx hash, log index, and deposit ID. +- Local runtime application state stores applied replay keys. +- Same-event replay returns `idempotent_replay`. +- Duplicate fixture replay rejects the second credit with `duplicate_replay_key`. + +Release replay protection: + +- Contract release calls derive a release ID. +- `releaseId` is marked used before value leaves the lockbox. +- Duplicate releases are rejected. +- Wrong release authority is rejected. +- Emergency stop blocks release. + +Proof commands: + +```powershell +forge test --match-path tests/bridge/BaseBridgeLockbox.t.sol +npm run bridge:pilot:mock:e2e +npm run bridge:credit:replay-check +``` + +Mock replay evidence: + +- replay key: `0xea93b7d168d2f1f6c4be4f95ba4d85aa2d07fc4298a720d180000a19d98481f0` +- first application status: `applied` +- same-event replay status: `idempotent_replay` +- replay credit status: `rejected` +- duplicate decision: `duplicate_replay_key_rejected` diff --git a/docs/agent-runs/production-l1-bridge/WITHDRAW_RELEASE_PROOF.md b/docs/agent-runs/production-l1-bridge/WITHDRAW_RELEASE_PROOF.md new file mode 100644 index 00000000..b607a0f9 --- /dev/null +++ b/docs/agent-runs/production-l1-bridge/WITHDRAW_RELEASE_PROOF.md @@ -0,0 +1,66 @@ +# Withdraw Release Proof + +Status: implemented for pilot evidence and contract release checks. + +Canonical withdrawal intent fields: + +- `withdrawalIntentId` +- `creditId` +- `depositId` +- `sourceChainId` +- `destinationChainId` +- `token` +- `amount` +- `flowchainAccount` +- `baseRecipient` +- `status` +- `requestedAt` + +Pilot withdrawal authorization fields: + +- `authorizationId` +- `withdrawalIntentId` +- `creditId` +- `depositId` +- `flowchainChainId` +- `destinationChainId` +- `token` +- `amount` +- `flowchainAccount` +- `baseRecipient` +- `withdrawalNonce` +- `signedBy` +- `signatureScheme` +- `signedPayloadHash` +- `signature` + +Mock withdrawal proof: + +- artifact: `services/bridge-relayer/out/real-value-pilot-e2e/bridge-withdrawal-intent.json` +- authorization artifact: `services/bridge-relayer/out/real-value-pilot-e2e/bridge-withdrawal-authorization.json` +- withdrawal intent ID: `0x1ed8e1c5b59f306a3892e7a6befbbeeb4417cd6656d2ab51457ac5ab7ec16b0f` +- authorization ID: `0x6dde86c11bc71f6d385e0dc2ba0d7874c7fb7ff2bff56d864014b30cb0b2c057` +- nonce: `1` +- local chain ID: `flowchain-local-pilot-v0` +- Base recipient: `0x4444444444444444444444444444444444444444` +- signature scheme: `flowchain-pilot-deterministic-test-signature-v0` + +Release evidence: + +- artifact: `services/bridge-relayer/out/real-value-pilot-e2e/bridge-release-evidence.json` +- release evidence ID: `0x6dfe4e3d9b05aa930b164fffb69a3068a0b8609417d62233dba0fcaa47350685` +- method: `releaseERC20` +- broadcast: `false` + +Contract checks: + +- wrong release authority rejected +- duplicate release rejected +- emergency stop blocks release +- pause does not block authorized release, by design, so the owner can recover during deposit pause +- release requires nonzero evidence hash + +Live boundary: + +- Mock authorization signatures are deterministic test evidence. +- A live owner pilot should source the local account signature from the wallet/runtime path and pair it with the canonical withdrawal intent before any operator release. diff --git a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md index 69961d13..4c46abb8 100644 --- a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md +++ b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md @@ -1,165 +1,51 @@ # FlowChain Base Bridge POC -Status: test-only bridge lane for local and Base Sepolia validation. +Status: capped owner pilot path for mock/local validation and guarded Base chain ID `8453` testing. -This bridge POC is designed so a small canary can be reviewed later without -claiming production bridge readiness. It is not audited, not trustless, not a -public bridge, and not approved for broad mainnet use. +This bridge lane is intentionally small and gated. It is not a public bridge, not a broad mainnet bridge, not audited, and not approved for unrestricted deposits. ## What Exists -- `contracts/bridge/BaseBridgeLockbox.sol`: non-upgradeable lockbox with owner, - explicit test release authority, pause, allowlisted tokens, per-deposit caps, - total caps, deposit records, replay guards, deposit events, and release hooks. -- `contracts/FlowChainSettlementSpine.sol`: compact local/test event spine for - bridge and FlowChain object commitments. -- `tests/bridge/BaseBridgeLockbox.t.sol`: Foundry coverage for token - allowlisting, ERC-20 deposits, native deposits, caps, pause behavior, - ownership, release, and replay protection. -- `tests/FlowChainSettlementSpine.t.sol`: Foundry coverage for authorized object - commitments and stable settlement event shape. -- `services/bridge-relayer/`: fixture-first and RPC-range observer that - converts explicit bridge deposit records into FlowChain bridge observation, - credit, withdrawal-intent, and runtime handoff JSON. -- `fixtures/bridge/base-sepolia-mock-deposit.json`: deterministic test deposit. -- `fixtures/bridge/local-runtime-bridge-handoff.json`: deterministic local - bridge handoff consumed by the runtime/control-plane until direct intake is - enabled. -- `schemas/flowmemory/bridge-*.schema.json`: bridge deposit, observation, - credit, withdrawal-intent, and runtime handoff contracts. -- `infra/scripts/bridge-base-sepolia-observe.ps1`: env-friendly Base Sepolia - observation wrapper that requires no private key. -- `infra/scripts/bridge-base-sepolia-smoke.ps1`: guarded Base Sepolia smoke. -- `infra/scripts/bridge-local-anvil-observe.ps1`: local Anvil observation - wrapper for chain id `31337`. -- `infra/scripts/bridge-base-mainnet-canary-read.ps1`: disabled-by-default - Base mainnet canary read wrapper. +- `contracts/bridge/BaseBridgeLockbox.sol`: non-upgradeable owner-controlled lockbox with native ETH and ERC20 custody paths, allowlist, per-deposit cap, cumulative total pilot cap, pause, emergency stop, deposit nonce accounting, release authority, and release replay protection. +- `tests/bridge/BaseBridgeLockbox.t.sol`: Foundry coverage for deposits, caps, pause, emergency stop, allowlist, wrong authority, release, replay, and zero-value failures. +- `services/bridge-relayer/src/observe-base-lockbox.ts`: fixture/RPC observer, deterministic evidence builder, local credit applier, withdrawal intent generator, and release evidence writer. +- `services/bridge-relayer/src/base8453-relay-monitor.ts`: low-latency Base `8453` relay with 12-confirmation eligibility, 5-second default polling, durable checkpoint, bounded recovery window, status/report output, and local FlowChain node intake. +- `services/bridge-relayer/src/base8453-tx-diagnostic.ts`: transaction hash diagnostic for receipt status, approved lockbox recipient, `lockNative(bytes32,bytes32)` selector `0x1326d1ec`, BridgeDeposit presence, direct ETH sends, reverts, wrong-contract calls, and cap failures. +- `services/bridge-relayer/src/bridge-pilot-e2e.ts`: no-RPC mock pilot E2E for observe, credit, replay, local usage handoff, withdrawal intent, and release evidence. +- `services/bridge-relayer/src/bridge-live-readiness-check.ts`: fail-closed live gate self-test. +- `infra/scripts/bridge-base8453-deploy.ps1`: dry-run and guarded broadcast deployment wrapper. +- `infra/scripts/bridge-base-mainnet-pilot-observe.ps1`: Base `8453` observation wrapper with chain, cap, confirmation, range, and acknowledgement gates. +- `infra/scripts/bridge-base8453-control.ps1`: pause, resume, and emergency stop wrapper. +- `infra/scripts/bridge-evidence-export.ps1`: secret-free bridge evidence bundle export. +- `fixtures/bridge/base8453-pilot-mock-deposit.json`: deterministic Base `8453` pilot deposit fixture. +- `schemas/flowmemory/bridge-*.schema.json`: bridge deposit, observation, credit, credit application, pilot evidence, withdrawal intent, withdrawal authorization, release evidence, local usage, and runtime handoff schemas. -## Architecture - -```text -Base Sepolia user/test wallet - -> BaseBridgeLockbox.lockERC20 or lockNative - -> BridgeDeposit event and DepositRecord state - -> bridge-relayer explicit reader/mock observer - -> BridgeObservation with replay key - -> BridgeCredit pending/applied local object - -> optional FlowChainSettlementSpine.commitObject bridge-deposit commitment - -> local runtime/control-plane/workbench handoff -``` - -The POC does not mint production assets on FlowChain. Local acceptance is a -fixture/control-plane event until the private/local runtime explicitly consumes -bridge deposit objects. +## Asset Decision -The handoff includes a workbench-ready timeline: +The lockbox supports both native Base ETH and ERC20 assets. The owner pilot activates one configured asset: -```text -deposit observed -> credit pending -> credit applied -> withdrawal requested -``` +- zero address in `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN` means native ETH +- nonzero address in `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN` means that ERC20 -Until live bridge intake is enabled, `fixtures/bridge/local-runtime-bridge-handoff.json` -is the exact file for the runtime/control-plane to consume. - -## Risk Model - -- Base mainnet uses real funds. Mainnet canary reads require - `--acknowledge-real-funds` and `--max-usd 25` or lower. -- The lockbox owner can configure tokens, caps, pause state, and the explicit - release authority. Only the release authority can call release hooks. That is - a test operator model, not a decentralized bridge model. -- The relayer reads explicit chains, contracts, and block ranges. It must not - broad-scan Base mainnet. -- No secrets, RPC keys, private keys, or seed phrases should be committed. -- Bridge observations are advisory local objects until the FlowChain runtime - verifies and accepts them. - -## Local Mock - -```powershell -npm install -npm run bridge:mock -npm run bridge:test -npm run bridge:local-credit:smoke -``` +The relayer refuses tokens not configured by `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN`. -Expected output: +## Architecture ```text -services/bridge-relayer/out/bridge-observation.json -services/bridge-relayer/out/bridge-credit.json -services/bridge-relayer/out/bridge-runtime-handoff.json -fixtures/bridge/local-runtime-bridge-handoff.json -``` - -## Base Sepolia Smoke - -Deploy the lockbox with Foundry or a deployment script, then run: - -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-sepolia-smoke.ps1 ` - -RpcUrl ` - -LockboxAddress ` - -FromBlock ` - -ToBlock -``` - -The script checks Base Sepolia chain id `84532`, requires an explicit lockbox, -requires an explicit block range, and writes local observation output. - -The root package also exposes an env-var smoke path that does not require a -private key: - -```powershell -$env:BASE_SEPOLIA_RPC_URL="" -$env:BASE_BRIDGE_LOCKBOX_ADDRESS="" -$env:BASE_BRIDGE_FROM_BLOCK="" -$env:BASE_BRIDGE_TO_BLOCK="" -npm run bridge:sepolia:observe -``` - -This command reads only `BridgeDeposit` logs from the explicit lockbox and -range, then writes observation, credit, and handoff JSON under -`services/bridge-relayer/out/`. - -## Local Anvil Observation - -Local Anvil is supported as a mock Base event lane with chain id `31337`. -Deploy `BaseBridgeLockbox`, emit one or more deposits, then run: - -```powershell -$env:ANVIL_BRIDGE_LOCKBOX_ADDRESS="" -$env:ANVIL_BRIDGE_FROM_BLOCK="" -$env:ANVIL_BRIDGE_TO_BLOCK="" -npm run bridge:anvil:observe -``` - -Use `-RpcUrl` or `ANVIL_RPC_URL` if the Anvil endpoint is not -`http://127.0.0.1:8545`. - -## Foundry Deploy Script - -The contract-side bridge spine has a dry-run-by-default Foundry script: - -```powershell -$env:FLOWCHAIN_BRIDGE_OWNER = "0x..." -$env:FLOWCHAIN_BRIDGE_RELEASE_AUTHORITY = "0x..." -$env:FLOWCHAIN_SETTLEMENT_SUBMITTER = "0x..." -$env:FLOWCHAIN_BRIDGE_ALLOW_NATIVE = "true" -$env:FLOWCHAIN_BRIDGE_NATIVE_PER_DEPOSIT_CAP = "100000000000000000" -$env:FLOWCHAIN_BRIDGE_NATIVE_TOTAL_CAP = "1000000000000000000" -$env:FLOWCHAIN_BRIDGE_ALLOW_ERC20 = "false" -$env:FLOWCHAIN_BRIDGE_ERC20_TOKEN = "0x0000000000000000000000000000000000000000" -$env:FLOWCHAIN_BRIDGE_ERC20_PER_DEPOSIT_CAP = "0" -$env:FLOWCHAIN_BRIDGE_ERC20_TOTAL_CAP = "0" - -forge script script/DeployBridgeSpine.s.sol:DeployBridgeSpine ` - --rpc-url http://127.0.0.1:8545 +Base 8453 owner wallet + -> BaseBridgeLockbox.lockNative or lockERC20 + -> BridgeDeposit event + -> low-latency guarded bridge relay + -> BridgeObservation with replay key + -> BridgeCredit + -> local exactly-once credit application state + -> running FlowChain local node intake for newly applied credits + -> local usage handoff and product gate + -> withdrawal intent + -> release evidence for operator review ``` -For Base Sepolia dry-run, use `--rpc-url $env:BASE_SEPOLIA_RPC_URL`. Add -`--broadcast` only after the environment values are explicit and the owner key -is intentionally supplied to Foundry. Do not commit RPC URLs or private keys. +Local credit evidence is JSON state and runtime handoff data until the private/local FlowChain runtime consumes bridge objects directly. ## Contract Event Schema @@ -170,102 +56,113 @@ event BridgeDeposit( bytes32 indexed depositId, uint256 indexed sourceChainId, address indexed sender, + address lockbox, address token, uint256 amount, bytes32 flowchainRecipient, uint256 nonce, - bytes32 metadataHash + bytes32 metadataHash, + bytes32 pilotModeTag ); ``` -`depositId` is: +`depositId` includes: ```text -keccak256(abi.encode( - BRIDGE_DEPOSIT_SCHEMA_ID, - block.chainid, - lockboxAddress, - sender, - token, - amount, - flowchainRecipient, - nonce, - metadataHash -)) +BRIDGE_DEPOSIT_SCHEMA_ID +block.chainid +lockbox address +sender +token +amount +flowchain recipient +nonce +metadata hash +pilot mode tag ``` -`BridgeRelease` is a test-only release event: +Receipt-derived fields such as `txHash`, `logIndex`, block number, block hash, and confirmations are written by the relayer evidence path. -```solidity -event BridgeRelease( - bytes32 indexed releaseId, - bytes32 indexed depositId, - address indexed recipient, - address token, - uint256 amount, - bytes32 evidenceHash -); -``` +## Live Gates -Release hooks require the configured release authority, a recorded deposit, -matching token, nonzero evidence hash, and available unreleased deposit amount. -They do not mint anything and do not prove FlowChain finality. +Live observation requires: -`FlowChainSettlementSpine` can record the local/private runtime's accepted -object commitments without implementing the runtime in Solidity: +- `eth_chainId == 0x2105` +- explicit operator acknowledgement +- configured lockbox address +- configured supported token +- configured confirmation depth +- bounded start and end block +- safe block range width +- durable checkpoint and bounded recovery window for relay mode +- per-deposit cap +- total pilot cap +- nonzero local recipient -```solidity -event FlowChainObjectCommitted( - bytes32 indexed objectId, - bytes32 indexed rootfieldId, - bytes32 indexed objectType, - address submitter, - bytes32 commitment, - bytes32 parentObjectId, - uint64 sequence, - uint64 committedAt, - string evidenceURI -); -``` +The readiness self-test proves missing env, missing acknowledgement, wrong chain, unapproved lockbox, unsupported token, missing confirmations, and broad block scan fail closed. -Bridge agents should use `BRIDGE_DEPOSIT_OBJECT` as `objectType` when committing -a FlowChain bridge-deposit object derived from a `BridgeDeposit`. Indexers still -derive `txHash`, `logIndex`, and block metadata from receipts and logs; those -fields are not emitted by the contracts. +## Required Env Names -## Base Mainnet Canary Read +- `FLOWCHAIN_BASE8453_RPC_URL` +- `FLOWCHAIN_BASE8453_DEPLOYER_PRIVATE_KEY` +- `FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS` +- `FLOWCHAIN_BASE8453_SUPPORTED_TOKEN` +- `FLOWCHAIN_BASE8453_FROM_BLOCK` +- `FLOWCHAIN_BASE8453_TO_BLOCK` +- `FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI` +- `FLOWCHAIN_PILOT_TOTAL_CAP_WEI` +- `FLOWCHAIN_PILOT_CONFIRMATIONS` +- `FLOWCHAIN_PILOT_OPERATOR_ACK` -Only after review, and only for a tiny capped canary: +Required acknowledgement value: -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-mainnet-canary-read.ps1 ` - -RpcUrl ` - -LockboxAddress ` - -FromBlock ` - -ToBlock ` - -AcknowledgeRealFunds ` - -MaxUsd 20 +```text +I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT ``` -The script checks Base mainnet chain id `8453` and refuses a canary above -`25` USD. It is read-only and prints the chain, lockbox, block range, max USD -guardrail, and broadcast status before it reads logs. - ## Commands ```powershell -forge test --match-path tests/bridge/BaseBridgeLockbox.t.sol -forge test --match-path tests/FlowChainSettlementSpine.t.sol -npm run bridge:test -npm run bridge:mock -npm run bridge:sepolia:observe +forge test +npm test --prefix services/bridge-relayer npm run bridge:local-credit:smoke -npm run flowchain:full-smoke +npm run bridge:pilot:mock:e2e +npm run flowchain:bridge:mock:e2e +npm run bridge:pilot:live:check +npm run flowchain:bridge:live:check +npm run flowchain:real-value-pilot:e2e +npm run flowchain:no-secret:scan git diff --check ``` -## Not Production +Pilot command aliases: + +```powershell +npm run bridge:deploy:dry-run +npm run bridge:deploy:base8453 -- -AcknowledgeBroadcast +npm run bridge:observe:base8453 +npm run bridge:relay:base8453 +npm run bridge:tx:diagnose -- --tx-hash --acknowledge-pilot +npm run bridge:credit:local +npm run bridge:credit:replay-check +npm run bridge:withdraw:intent +npm run bridge:release:evidence +npm run bridge:pause -- -Execute +npm run bridge:resume -- -Execute +npm run bridge:emergency-stop -- -Execute +npm run bridge:evidence:export +``` + +## Proof Artifacts + +- `docs/agent-runs/production-l1-bridge/CONTRACT_PROOF.md` +- `docs/agent-runs/production-l1-bridge/RELAYER_PROOF.md` +- `docs/agent-runs/production-l1-bridge/MOCK_PILOT_E2E_PROOF.md` +- `docs/agent-runs/production-l1-bridge/REPLAY_PROOF.md` +- `docs/agent-runs/production-l1-bridge/LIVE_READINESS_PROOF.md` +- `docs/agent-runs/production-l1-bridge/REAL_FUNDS_PILOT_RUNBOOK.md` +- `docs/agent-runs/production-l1-bridge/HANDOFF.md` + +## Boundary -This POC is not a production bridge, not a bridge launch, not audited, not a -tokenomics system, and not a public user deposit system. It exists so the -private/local FlowChain package can test a Base-origin deposit signal safely. +This path is for a capped owner pilot. It does not remove the need for independent review, conservative caps, operator controls, and owner-provided live credentials. diff --git a/fixtures/bridge/base8453-pilot-duplicate-mock-deposits.json b/fixtures/bridge/base8453-pilot-duplicate-mock-deposits.json new file mode 100644 index 00000000..7678df34 --- /dev/null +++ b/fixtures/bridge/base8453-pilot-duplicate-mock-deposits.json @@ -0,0 +1,41 @@ +{ + "schema": "flowmemory.bridge_deposit_batch.v0", + "deposits": [ + { + "schema": "flowmemory.bridge_deposit.v0", + "depositId": "0x8453000000000000000000000000000000000000000000000000000000000001", + "sourceChainId": 8453, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0x8453000000000000000000000000000000000000000000000000000000000002", + "logIndex": 0, + "sourceBlockNumber": "100", + "sourceBlockHash": "0x8453000000000000000000000000000000000000000000000000000000000003", + "transactionIndex": 1, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "1", + "metadataHash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "status": "observed" + }, + { + "schema": "flowmemory.bridge_deposit.v0", + "depositId": "0x8453000000000000000000000000000000000000000000000000000000000001", + "sourceChainId": 8453, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0x8453000000000000000000000000000000000000000000000000000000000002", + "logIndex": 0, + "sourceBlockNumber": "100", + "sourceBlockHash": "0x8453000000000000000000000000000000000000000000000000000000000003", + "transactionIndex": 1, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "1", + "metadataHash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "status": "observed" + } + ] +} diff --git a/fixtures/bridge/base8453-pilot-mock-deposit.json b/fixtures/bridge/base8453-pilot-mock-deposit.json new file mode 100644 index 00000000..b3fc271f --- /dev/null +++ b/fixtures/bridge/base8453-pilot-mock-deposit.json @@ -0,0 +1,18 @@ +{ + "schema": "flowmemory.bridge_deposit.v0", + "depositId": "0x8453000000000000000000000000000000000000000000000000000000000001", + "sourceChainId": 8453, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0x8453000000000000000000000000000000000000000000000000000000000002", + "logIndex": 0, + "sourceBlockNumber": "100", + "sourceBlockHash": "0x8453000000000000000000000000000000000000000000000000000000000003", + "transactionIndex": 1, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "1", + "metadataHash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "status": "observed" +} diff --git a/fixtures/bridge/local-runtime-bridge-handoff.json b/fixtures/bridge/local-runtime-bridge-handoff.json index 29d56b9d..aacdb770 100644 --- a/fixtures/bridge/local-runtime-bridge-handoff.json +++ b/fixtures/bridge/local-runtime-bridge-handoff.json @@ -1,6 +1,6 @@ { "schema": "flowmemory.bridge_runtime_handoff.v0", - "handoffId": "0xb8f818f1c45a864a7134b298b993952edda161824a4120c7716ce950fe63a2ca", + "handoffId": "0x36fcd03ec701406d4af9a5c8702ecaa4fcb9ead45b72e3a7e11cf33649b36c93", "generatedAt": "2026-05-13T00:00:00.000Z", "mode": "mock", "productionReady": false, @@ -78,6 +78,47 @@ "productionReady": false } ], + "runtimeApplications": [ + { + "schema": "flowmemory.bridge_runtime_credit_application.v0", + "applicationId": "0xe4799e2732957923382111ffcf05ae5a28ad441d1a998417097c31165dc6ab32", + "creditId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269", + "replayKey": "0x9c97eb0fa65cb3eec9274cb0c9e925351608e7abe6980fe2525820048bd81e09", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "amount": "20000000", + "status": "applied", + "appliedAt": "2026-05-13T00:00:00.000Z", + "applyCount": 1, + "localOnly": true, + "productionReady": false + } + ], + "pilotEvidence": [], + "releaseEvidences": [ + { + "schema": "flowmemory.bridge_release_evidence.v0", + "releaseEvidenceId": "0x0ae60c9a0b75a071172ee7cf80959d9b35c6ea331af5ccca7bc94eda23b8b000", + "generatedAt": "2026-05-13T00:00:00.000Z", + "withdrawalIntentId": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751", + "creditId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269", + "sourceChainId": 84532, + "destinationChainId": 84532, + "lockbox": "0x1111111111111111111111111111111111111111", + "releaseCall": { + "method": "releaseERC20", + "recipient": "0x4444444444444444444444444444444444444444", + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "evidenceHash": "0x697d114347dc96921b6b6df9bd9b7b3dd436e4ebe51d6d9e5c1c06c682ae5b58", + "broadcast": false + }, + "operatorNote": "Pilot release evidence only. Review before any separate release-authority transaction; this relayer does not broadcast.", + "productionReady": false, + "localOnly": true + } + ], "replayProtection": { "strategy": "source-chain-contract-tx-log-deposit", "replayKeys": [ diff --git a/infra/scripts/bridge-base-mainnet-pilot-observe.ps1 b/infra/scripts/bridge-base-mainnet-pilot-observe.ps1 new file mode 100644 index 00000000..fa664f03 --- /dev/null +++ b/infra/scripts/bridge-base-mainnet-pilot-observe.ps1 @@ -0,0 +1,141 @@ +param( + [string]$RpcUrl = $env:FLOWCHAIN_BASE8453_RPC_URL, + + [string]$LockboxAddress = $env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS, + + [string]$ApprovedLockboxAddress = $(if ($env:FLOWCHAIN_BASE8453_APPROVED_LOCKBOX_ADDRESS) { $env:FLOWCHAIN_BASE8453_APPROVED_LOCKBOX_ADDRESS } else { $env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS }), + + [string]$SupportedToken = $env:FLOWCHAIN_BASE8453_SUPPORTED_TOKEN, + + [string]$FromBlock = $env:FLOWCHAIN_BASE8453_FROM_BLOCK, + + [string]$ToBlock = $env:FLOWCHAIN_BASE8453_TO_BLOCK, + + [string]$Confirmations = $env:FLOWCHAIN_PILOT_CONFIRMATIONS, + + [string]$MaxUsd = $(if ($env:FLOWCHAIN_PILOT_MAX_USD) { $env:FLOWCHAIN_PILOT_MAX_USD } else { "1" }), + + [string]$MaxDepositAmount = $env:FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI, + + [string]$TotalCapAmount = $env:FLOWCHAIN_PILOT_TOTAL_CAP_WEI, + + [switch]$OperatorAck, + + [switch]$ApplyCredit, + + [switch]$WithdrawalIntent, + + [string]$RuntimeState = "services/bridge-relayer/out/base8453-pilot-credit-application-state.json", + + [string]$Out = "services/bridge-relayer/out/base8453-pilot-bridge-observation.json", + + [string]$CreditOut = "services/bridge-relayer/out/base8453-pilot-bridge-credit.json", + + [string]$HandoffOut = "services/bridge-relayer/out/base8453-pilot-bridge-handoff.json", + + [string]$EvidenceOut = "services/bridge-relayer/out/base8453-pilot-evidence.json", + + [string]$WithdrawalOut = "services/bridge-relayer/out/base8453-pilot-withdrawal-intent.json", + + [string]$ReleaseEvidenceOut = "services/bridge-relayer/out/base8453-pilot-release-evidence.json" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location -LiteralPath $repoRoot + +function Write-NextCommand { + param( + [Parameter(Mandatory = $true)] + [string]$Step, + + [Parameter(Mandatory = $true)] + [string]$Command + ) + + Write-Host "$Step complete." -ForegroundColor Green + Write-Host "Next operator command: $Command" -ForegroundColor Cyan +} + +$requiredAck = "I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT" +$ackFromEnv = $env:FLOWCHAIN_PILOT_OPERATOR_ACK -eq $requiredAck +$acknowledged = [bool]$OperatorAck -or $ackFromEnv + +$missing = @() +if ([string]::IsNullOrWhiteSpace($RpcUrl)) { $missing += "FLOWCHAIN_BASE8453_RPC_URL or -RpcUrl" } +if ([string]::IsNullOrWhiteSpace($LockboxAddress)) { $missing += "FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS or -LockboxAddress" } +if ([string]::IsNullOrWhiteSpace($ApprovedLockboxAddress)) { $missing += "FLOWCHAIN_BASE8453_APPROVED_LOCKBOX_ADDRESS or -ApprovedLockboxAddress" } +if ([string]::IsNullOrWhiteSpace($SupportedToken)) { $missing += "FLOWCHAIN_BASE8453_SUPPORTED_TOKEN or -SupportedToken" } +if ([string]::IsNullOrWhiteSpace($FromBlock)) { $missing += "FLOWCHAIN_BASE8453_FROM_BLOCK or -FromBlock" } +if ([string]::IsNullOrWhiteSpace($ToBlock)) { $missing += "FLOWCHAIN_BASE8453_TO_BLOCK or -ToBlock" } +if ([string]::IsNullOrWhiteSpace($Confirmations)) { $missing += "FLOWCHAIN_PILOT_CONFIRMATIONS or -Confirmations" } +if ([string]::IsNullOrWhiteSpace($MaxDepositAmount)) { $missing += "FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI or -MaxDepositAmount" } +if ([string]::IsNullOrWhiteSpace($TotalCapAmount)) { $missing += "FLOWCHAIN_PILOT_TOTAL_CAP_WEI or -TotalCapAmount" } +if (-not $acknowledged) { $missing += "FLOWCHAIN_PILOT_OPERATOR_ACK=$requiredAck or -OperatorAck" } + +if ($missing.Count -gt 0) { + throw "Base 8453 pilot observation needs: $($missing -join ', '). No private key is required by this relayer." +} + +Write-Host "Preparing Base 8453 bridge pilot observation." -ForegroundColor Yellow +Write-Host "Chain: Base public network (8453 / 0x2105)" +Write-Host "Lockbox: $LockboxAddress" +Write-Host "Approved lockbox: $ApprovedLockboxAddress" +Write-Host "Supported token: $SupportedToken" +Write-Host "Block range: $FromBlock-$ToBlock" +Write-Host "Confirmation depth: $Confirmations" +Write-Host "Max USD guardrail: $MaxUsd" +Write-Host "Max deposit amount: $MaxDepositAmount" +Write-Host "Total cap amount: $TotalCapAmount" +Write-Host "Broadcast: false; this relayer never sends release transactions." + +Write-NextCommand ` + -Step "Step 1" ` + -Command "npm run bridge:observe -- --mode base-mainnet-pilot --rpc-url `$env:FLOWCHAIN_BASE8453_RPC_URL --lockbox-address `$env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS --approved-lockbox `$env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS --supported-token `$env:FLOWCHAIN_BASE8453_SUPPORTED_TOKEN --from-block `$env:FLOWCHAIN_BASE8453_FROM_BLOCK --to-block `$env:FLOWCHAIN_BASE8453_TO_BLOCK --confirmations `$env:FLOWCHAIN_PILOT_CONFIRMATIONS --acknowledge-pilot --acknowledge-real-funds --max-usd `$env:FLOWCHAIN_PILOT_MAX_USD --max-deposit-amount `$env:FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI --total-cap-amount `$env:FLOWCHAIN_PILOT_TOTAL_CAP_WEI" + +$arguments = @( + "run", "bridge:observe", "--", + "--mode", "base-mainnet-pilot", + "--rpc-url", $RpcUrl, + "--lockbox-address", $LockboxAddress, + "--approved-lockbox", $ApprovedLockboxAddress, + "--supported-token", $SupportedToken, + "--from-block", $FromBlock, + "--to-block", $ToBlock, + "--confirmations", $Confirmations, + "--acknowledge-pilot", + "--acknowledge-real-funds", + "--max-usd", $MaxUsd, + "--max-deposit-amount", $MaxDepositAmount, + "--total-cap-amount", $TotalCapAmount, + "--runtime-state", $RuntimeState, + "--out", $Out, + "--credit-out", $CreditOut, + "--handoff-out", $HandoffOut, + "--evidence-out", $EvidenceOut +) + +if ($ApplyCredit) { + $arguments += "--apply-credit" +} +if ($WithdrawalIntent) { + $arguments += @("--withdrawal-intent", "--withdrawal-out", $WithdrawalOut, "--release-evidence-out", $ReleaseEvidenceOut) +} + +npm @arguments +if ($LASTEXITCODE -ne 0) { + throw "Base 8453 pilot bridge observer failed with exit code $LASTEXITCODE." +} + +Write-NextCommand -Step "Step 2" -Command "Get-Content $EvidenceOut" + +if ($WithdrawalIntent) { + Write-NextCommand -Step "Step 3" -Command "Get-Content $ReleaseEvidenceOut" +} +else { + Write-NextCommand -Step "Step 3" -Command "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-mainnet-pilot-observe.ps1 -OperatorAck -ApplyCredit -WithdrawalIntent" +} + +Write-NextCommand -Step "Step 4" -Command "npm run flowchain:product-e2e" diff --git a/infra/scripts/bridge-base8453-control.ps1 b/infra/scripts/bridge-base8453-control.ps1 new file mode 100644 index 00000000..d7ad36c7 --- /dev/null +++ b/infra/scripts/bridge-base8453-control.ps1 @@ -0,0 +1,73 @@ +param( + [ValidateSet("Pause", "Resume", "EmergencyStop")] + [string]$Action, + + [switch]$Execute +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location -LiteralPath $repoRoot + +$requiredAck = "I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT" + +function Require-Env { + param([Parameter(Mandatory = $true)][string]$Name) + $value = [Environment]::GetEnvironmentVariable($Name, "Process") + if ([string]::IsNullOrWhiteSpace($value)) { + throw "$Name is required for bridge $Action." + } + return $value +} + +function Invoke-ChainCheck { + param([Parameter(Mandatory = $true)][string]$RpcUrl) + $body = @{ jsonrpc = "2.0"; id = 1; method = "eth_chainId"; params = @() } | ConvertTo-Json -Compress + try { + $response = Invoke-RestMethod -Uri $RpcUrl -Method Post -ContentType "application/json" -Body $body -TimeoutSec 20 + } + catch { + throw "Could not read eth_chainId from FLOWCHAIN_BASE8453_RPC_URL. The endpoint is not printed." + } + if ($response.result -ne "0x2105") { + throw "Wrong chain id for bridge $Action: expected 0x2105." + } +} + +$ack = Require-Env -Name "FLOWCHAIN_PILOT_OPERATOR_ACK" +if ($ack -ne $requiredAck) { + throw "FLOWCHAIN_PILOT_OPERATOR_ACK must equal $requiredAck." +} +$rpcUrl = Require-Env -Name "FLOWCHAIN_BASE8453_RPC_URL" +$privateKey = Require-Env -Name "FLOWCHAIN_BASE8453_DEPLOYER_PRIVATE_KEY" +$lockbox = Require-Env -Name "FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS" +if ($lockbox -notmatch '^0x[0-9a-fA-F]{40}$') { + throw "FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS must be a 20-byte hex address." +} +Invoke-ChainCheck -RpcUrl $rpcUrl + +$method = if ($Action -eq "EmergencyStop") { "setEmergencyStopped(bool)" } else { "setPaused(bool)" } +$value = if ($Action -eq "Resume") { "false" } else { "true" } + +if (-not $Execute) { + $scriptName = switch ($Action) { + "Pause" { "bridge:pause" } + "Resume" { "bridge:resume" } + "EmergencyStop" { "bridge:emergency-stop" } + } + Write-Host "Bridge $Action preflight passed. No transaction was broadcast." + Write-Host "Exact execute command: npm run $scriptName -- -Execute" + return +} + +if (-not (Get-Command cast -ErrorAction SilentlyContinue)) { + throw "cast is required for bridge $Action execution." +} + +Write-Host "Broadcasting bridge $Action. RPC URL and private key are not printed." +& cast send $lockbox $method $value --rpc-url $rpcUrl --private-key $privateKey +if ($LASTEXITCODE -ne 0) { + throw "cast send failed for bridge $Action." +} diff --git a/infra/scripts/bridge-base8453-deploy.ps1 b/infra/scripts/bridge-base8453-deploy.ps1 new file mode 100644 index 00000000..d33dcf84 --- /dev/null +++ b/infra/scripts/bridge-base8453-deploy.ps1 @@ -0,0 +1,222 @@ +param( + [ValidateSet("DryRun", "Broadcast")] + [string]$Mode = "DryRun", + + [switch]$AcknowledgeBroadcast, + + [string]$ReportPath = "services/bridge-relayer/out/base8453-deploy-readiness.json" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location -LiteralPath $repoRoot + +$requiredAck = "I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT" +$zeroAddress = "0x0000000000000000000000000000000000000000" + +function Get-BridgeEnv { + param([Parameter(Mandatory = $true)][string]$Name) + return [Environment]::GetEnvironmentVariable($Name, "Process") +} + +function Require-BridgeEnv { + param([Parameter(Mandatory = $true)][string]$Name) + $value = Get-BridgeEnv -Name $Name + if ([string]::IsNullOrWhiteSpace($value)) { + throw "$Name is required for Base 8453 bridge deploy $Mode." + } + return $value +} + +function Assert-Address { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Value + ) + if ($Value -notmatch '^0x[0-9a-fA-F]{40}$') { + throw "$Name must be a 20-byte hex address." + } +} + +function Assert-Uint { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Value + ) + if ($Value -notmatch '^[0-9]+$') { + throw "$Name must be a decimal integer." + } + return [System.Numerics.BigInteger]::Parse($Value, [System.Globalization.CultureInfo]::InvariantCulture) +} + +function Invoke-BaseChainCheck { + param([Parameter(Mandatory = $true)][string]$RpcUrl) + $body = @{ jsonrpc = "2.0"; id = 1; method = "eth_chainId"; params = @() } | ConvertTo-Json -Compress + try { + $response = Invoke-RestMethod -Uri $RpcUrl -Method Post -ContentType "application/json" -Body $body -TimeoutSec 20 + } + catch { + throw "Could not read eth_chainId from FLOWCHAIN_BASE8453_RPC_URL. Do not print or commit the endpoint." + } + if ($response.result -ne "0x2105") { + throw "Wrong chain id for Base 8453 deploy: expected 0x2105." + } +} + +function Get-DeployerAddress { + param([Parameter(Mandatory = $true)][string]$PrivateKey) + if (-not (Get-Command cast -ErrorAction SilentlyContinue)) { + throw "cast is required to derive the deployer address without logging the private key." + } + $address = (& cast wallet address --private-key $PrivateKey 2>$null | Select-Object -First 1) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($address)) { + throw "Could not derive deployer address from FLOWCHAIN_BASE8453_DEPLOYER_PRIVATE_KEY." + } + $trimmed = ($address -as [string]).Trim() + Assert-Address -Name "derived deployer address" -Value $trimmed + return $trimmed +} + +function Write-JsonReport { + param([Parameter(Mandatory = $true)][object]$Value) + $fullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $ReportPath)) + $fullRoot = [System.IO.Path]::GetFullPath($repoRoot).TrimEnd('\') + if (-not $fullPath.StartsWith($fullRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "ReportPath must stay inside the repository." + } + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $fullPath) | Out-Null + $Value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $fullPath -Encoding UTF8 + Write-Host "Wrote $fullPath" +} + +$requiredEnvNames = @( + "FLOWCHAIN_BASE8453_RPC_URL", + "FLOWCHAIN_BASE8453_DEPLOYER_PRIVATE_KEY", + "FLOWCHAIN_BASE8453_SUPPORTED_TOKEN", + "FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI", + "FLOWCHAIN_PILOT_TOTAL_CAP_WEI", + "FLOWCHAIN_PILOT_OPERATOR_ACK" +) + +$missing = @() +foreach ($name in $requiredEnvNames) { + if ([string]::IsNullOrWhiteSpace((Get-BridgeEnv -Name $name))) { + $missing += $name + } +} + +if ($Mode -eq "DryRun" -and $missing.Count -gt 0) { + Write-JsonReport -Value ([ordered]@{ + schema = "flowmemory.bridge_base8453_deploy_readiness.v0" + mode = $Mode + status = "missing-env-safe" + missingEnvNames = $missing + requiredEnvNames = $requiredEnvNames + exactDryRunCommand = "npm run bridge:deploy:dry-run" + exactBroadcastCommand = "npm run bridge:deploy:base8453 -- -AcknowledgeBroadcast" + noSecrets = $true + }) + Write-Host "Dry-run deploy readiness is safely blocked on missing env names." + return +} + +$rpcUrl = Require-BridgeEnv -Name "FLOWCHAIN_BASE8453_RPC_URL" +$privateKey = Require-BridgeEnv -Name "FLOWCHAIN_BASE8453_DEPLOYER_PRIVATE_KEY" +$supportedToken = Require-BridgeEnv -Name "FLOWCHAIN_BASE8453_SUPPORTED_TOKEN" +$maxDeposit = (Assert-Uint -Name "FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI" -Value (Require-BridgeEnv -Name "FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI")).ToString() +$totalCap = (Assert-Uint -Name "FLOWCHAIN_PILOT_TOTAL_CAP_WEI" -Value (Require-BridgeEnv -Name "FLOWCHAIN_PILOT_TOTAL_CAP_WEI")).ToString() +Assert-Address -Name "FLOWCHAIN_BASE8453_SUPPORTED_TOKEN" -Value $supportedToken + +if ([System.Numerics.BigInteger]::Parse($totalCap) -lt [System.Numerics.BigInteger]::Parse($maxDeposit)) { + throw "FLOWCHAIN_PILOT_TOTAL_CAP_WEI must be greater than or equal to FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI." +} + +Invoke-BaseChainCheck -RpcUrl $rpcUrl + +$ack = Require-BridgeEnv -Name "FLOWCHAIN_PILOT_OPERATOR_ACK" +if ($ack -ne $requiredAck) { + throw "FLOWCHAIN_PILOT_OPERATOR_ACK must equal $requiredAck." +} + +$deployer = Get-DeployerAddress -PrivateKey $privateKey +$owner = $(if (Get-BridgeEnv -Name "FLOWCHAIN_BASE8453_OWNER_ADDRESS") { Get-BridgeEnv -Name "FLOWCHAIN_BASE8453_OWNER_ADDRESS" } else { $deployer }) +$releaseAuthority = $(if (Get-BridgeEnv -Name "FLOWCHAIN_BASE8453_RELEASE_AUTHORITY_ADDRESS") { Get-BridgeEnv -Name "FLOWCHAIN_BASE8453_RELEASE_AUTHORITY_ADDRESS" } else { $deployer }) +$settlementSubmitter = $(if (Get-BridgeEnv -Name "FLOWCHAIN_BASE8453_SETTLEMENT_SUBMITTER_ADDRESS") { Get-BridgeEnv -Name "FLOWCHAIN_BASE8453_SETTLEMENT_SUBMITTER_ADDRESS" } else { $deployer }) +Assert-Address -Name "owner" -Value $owner +Assert-Address -Name "release authority" -Value $releaseAuthority +Assert-Address -Name "settlement submitter" -Value $settlementSubmitter + +$allowNative = ($supportedToken.ToLowerInvariant() -eq $zeroAddress) +$allowErc20 = -not $allowNative +$erc20PerDepositCap = if ($allowErc20) { $maxDeposit } else { "0" } +$erc20TotalCap = if ($allowErc20) { $totalCap } else { "0" } +$nativePerDepositCap = if ($allowNative) { $maxDeposit } else { "0" } +$nativeTotalCap = if ($allowNative) { $totalCap } else { "0" } + +$report = [ordered]@{ + schema = "flowmemory.bridge_base8453_deploy_readiness.v0" + mode = $Mode + status = if ($Mode -eq "DryRun") { "ready-no-broadcast" } else { "broadcast-requested" } + baseChainId = 8453 + supportedToken = $supportedToken + assetPath = if ($allowNative) { "native-eth" } else { "erc20" } + owner = $owner + releaseAuthority = $releaseAuthority + settlementSubmitter = $settlementSubmitter + maxDepositWei = $maxDeposit + totalCapWei = $totalCap + pausedAfterDeploy = $false + emergencyStoppedAfterDeploy = $false + exactDryRunCommand = "npm run bridge:deploy:dry-run" + exactBroadcastCommand = "npm run bridge:deploy:base8453 -- -AcknowledgeBroadcast" + noSecrets = $true +} + +if ($Mode -eq "DryRun") { + Write-JsonReport -Value $report + Write-Host "Deploy dry-run passed. No transaction was broadcast." + return +} + +if (-not $AcknowledgeBroadcast) { + throw "Broadcast mode requires -AcknowledgeBroadcast." +} +if (-not (Get-Command forge -ErrorAction SilentlyContinue)) { + throw "forge is required for Base 8453 deployment broadcast." +} + +$mappedEnv = @{ + FLOWCHAIN_BRIDGE_OWNER = $owner + FLOWCHAIN_BRIDGE_RELEASE_AUTHORITY = $releaseAuthority + FLOWCHAIN_SETTLEMENT_SUBMITTER = $settlementSubmitter + FLOWCHAIN_BRIDGE_ALLOW_NATIVE = if ($allowNative) { "true" } else { "false" } + FLOWCHAIN_BRIDGE_NATIVE_PER_DEPOSIT_CAP = $nativePerDepositCap + FLOWCHAIN_BRIDGE_NATIVE_TOTAL_CAP = $nativeTotalCap + FLOWCHAIN_BRIDGE_ALLOW_ERC20 = if ($allowErc20) { "true" } else { "false" } + FLOWCHAIN_BRIDGE_ERC20_TOKEN = if ($allowErc20) { $supportedToken } else { $zeroAddress } + FLOWCHAIN_BRIDGE_ERC20_PER_DEPOSIT_CAP = $erc20PerDepositCap + FLOWCHAIN_BRIDGE_ERC20_TOTAL_CAP = $erc20TotalCap +} + +$previous = @{} +try { + foreach ($name in $mappedEnv.Keys) { + $previous[$name] = [Environment]::GetEnvironmentVariable($name, "Process") + [Environment]::SetEnvironmentVariable($name, [string]$mappedEnv[$name], "Process") + } + Write-Host "Broadcasting Base 8453 bridge deploy. RPC URL and private key are not printed." + & forge script script/DeployBridgeSpine.s.sol:DeployBridgeSpine --rpc-url $rpcUrl --private-key $privateKey --broadcast + if ($LASTEXITCODE -ne 0) { + throw "forge broadcast failed." + } +} +finally { + foreach ($name in $mappedEnv.Keys) { + [Environment]::SetEnvironmentVariable($name, $previous[$name], "Process") + } +} + +$report.status = "broadcast-complete" +Write-JsonReport -Value $report diff --git a/infra/scripts/bridge-base8453-relay.ps1 b/infra/scripts/bridge-base8453-relay.ps1 new file mode 100644 index 00000000..4000e36c --- /dev/null +++ b/infra/scripts/bridge-base8453-relay.ps1 @@ -0,0 +1,85 @@ +param( + [string]$RpcUrl = $env:FLOWCHAIN_BASE8453_RPC_URL, + [string]$LockboxAddress = $(if ($env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS) { $env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS } else { "0xe731Bc6b117d92deDCA40a7ccAec11d16205026a" }), + [string]$ApprovedLockboxAddress = $(if ($env:FLOWCHAIN_BASE8453_APPROVED_LOCKBOX_ADDRESS) { $env:FLOWCHAIN_BASE8453_APPROVED_LOCKBOX_ADDRESS } elseif ($env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS) { $env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS } else { "0xe731Bc6b117d92deDCA40a7ccAec11d16205026a" }), + [string]$SupportedToken = $(if ($env:FLOWCHAIN_BASE8453_SUPPORTED_TOKEN) { $env:FLOWCHAIN_BASE8453_SUPPORTED_TOKEN } else { "0x0000000000000000000000000000000000000000" }), + [string]$StartBlock = $env:FLOWCHAIN_BASE8453_FROM_BLOCK, + [string]$Confirmations = $(if ($env:FLOWCHAIN_PILOT_CONFIRMATIONS) { $env:FLOWCHAIN_PILOT_CONFIRMATIONS } else { "12" }), + [string]$PollMs = $(if ($env:FLOWCHAIN_BASE8453_RELAY_POLL_MS) { $env:FLOWCHAIN_BASE8453_RELAY_POLL_MS } else { "5000" }), + [string]$MaxScanBlocks = $(if ($env:FLOWCHAIN_BASE8453_RELAY_MAX_SCAN_BLOCKS) { $env:FLOWCHAIN_BASE8453_RELAY_MAX_SCAN_BLOCKS } else { "500" }), + [string]$RecoveryWindowBlocks = $(if ($env:FLOWCHAIN_BASE8453_RELAY_RECOVERY_WINDOW_BLOCKS) { $env:FLOWCHAIN_BASE8453_RELAY_RECOVERY_WINDOW_BLOCKS } else { "128" }), + [string]$MaxUsd = $(if ($env:FLOWCHAIN_PILOT_MAX_USD) { $env:FLOWCHAIN_PILOT_MAX_USD } else { "1" }), + [string]$MaxDepositAmount = $env:FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI, + [string]$TotalCapAmount = $env:FLOWCHAIN_PILOT_TOTAL_CAP_WEI, + [ValidateSet("inbox", "direct", "off")] + [string]$NodeMode = "inbox", + [switch]$OperatorAck, + [switch]$Once +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location -LiteralPath $repoRoot + +$requiredAck = "I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT" +$ackFromEnv = $env:FLOWCHAIN_PILOT_OPERATOR_ACK -eq $requiredAck +$acknowledged = [bool]$OperatorAck -or $ackFromEnv + +$missing = @() +if ([string]::IsNullOrWhiteSpace($RpcUrl)) { $missing += "FLOWCHAIN_BASE8453_RPC_URL or -RpcUrl" } +if ([string]::IsNullOrWhiteSpace($MaxDepositAmount)) { $missing += "FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI or -MaxDepositAmount" } +if ([string]::IsNullOrWhiteSpace($TotalCapAmount)) { $missing += "FLOWCHAIN_PILOT_TOTAL_CAP_WEI or -TotalCapAmount" } +if (-not $acknowledged) { $missing += "FLOWCHAIN_PILOT_OPERATOR_ACK=$requiredAck or -OperatorAck" } + +if ($missing.Count -gt 0) { + throw "Base 8453 low-latency relay needs: $($missing -join ', '). No private key is required by this relay." +} + +Write-Host "Preparing Base 8453 low-latency bridge relay." -ForegroundColor Yellow +Write-Host "Chain: Base public network (8453 / 0x2105)" +Write-Host "Lockbox: $LockboxAddress" +Write-Host "Approved lockbox: $ApprovedLockboxAddress" +Write-Host "Supported token: $SupportedToken" +Write-Host "Start block: $(if ([string]::IsNullOrWhiteSpace($StartBlock)) { 'latest bounded recovery window' } else { $StartBlock })" +Write-Host "Confirmation depth: $Confirmations" +Write-Host "Polling interval: $PollMs ms" +Write-Host "Max scan blocks: $MaxScanBlocks" +Write-Host "Recovery window blocks: $RecoveryWindowBlocks" +Write-Host "Node ingest mode: $NodeMode" +Write-Host "Broadcast: false; this relay never sends release transactions." + +$arguments = @( + "run", "bridge:relay:base8453", "--", + "--rpc-url", $RpcUrl, + "--lockbox-address", $LockboxAddress, + "--approved-lockbox", $ApprovedLockboxAddress, + "--supported-token", $SupportedToken, + "--confirmations", $Confirmations, + "--poll-ms", $PollMs, + "--max-scan-blocks", $MaxScanBlocks, + "--recovery-window-blocks", $RecoveryWindowBlocks, + "--max-usd", $MaxUsd, + "--max-deposit-amount", $MaxDepositAmount, + "--total-cap-amount", $TotalCapAmount, + "--node-mode", $NodeMode, + "--acknowledge-pilot", + "--acknowledge-real-funds" +) + +if (-not [string]::IsNullOrWhiteSpace($StartBlock)) { + $arguments += @("--from-block", $StartBlock) +} + +if ($Once) { + $arguments += "--once" +} + +npm @arguments +if ($LASTEXITCODE -ne 0) { + throw "Base 8453 low-latency relay failed with exit code $LASTEXITCODE." +} + +Write-Host "Relay artifacts: devnet/local/live-base8453-relay/" -ForegroundColor Cyan + diff --git a/infra/scripts/bridge-evidence-export.ps1 b/infra/scripts/bridge-evidence-export.ps1 new file mode 100644 index 00000000..b88bfdfe --- /dev/null +++ b/infra/scripts/bridge-evidence-export.ps1 @@ -0,0 +1,91 @@ +param( + [string]$EvidenceDir = "services/bridge-relayer/out", + [string]$BundlePath = "services/bridge-relayer/out/base8453-bridge-evidence.zip" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location -LiteralPath $repoRoot + +function Assert-InsideRepo { + param([Parameter(Mandatory = $true)][string]$Path) + $fullRoot = [System.IO.Path]::GetFullPath($repoRoot).TrimEnd('\') + $fullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) + if (-not $fullPath.StartsWith($fullRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Path must stay inside repository: $Path" + } + return $fullPath +} + +function Assert-NoSecretText { + param([Parameter(Mandatory = $true)][string]$Path) + $patterns = @( + "BEGIN PRIVATE KEY", + "BEGIN RSA PRIVATE KEY", + "BEGIN OPENSSH PRIVATE KEY", + "mnemonic", + "seed phrase", + "apiKey", + "webhook" + ) + foreach ($file in Get-ChildItem -LiteralPath $Path -Recurse -File -Include *.json,*.md,*.txt,*.log) { + $text = Get-Content -Raw -LiteralPath $file.FullName + foreach ($pattern in $patterns) { + if ($text.IndexOf($pattern, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { + throw "Potential secret marker found in evidence file $($file.FullName)." + } + } + } +} + +$source = Assert-InsideRepo -Path $EvidenceDir +$bundle = Assert-InsideRepo -Path $BundlePath +$exportRoot = Split-Path -Parent $bundle +$stage = Join-Path $exportRoot "base8453-bridge-evidence-stage" + +if (-not (Test-Path -LiteralPath $source)) { + New-Item -ItemType Directory -Force -Path $source | Out-Null +} +if (Test-Path -LiteralPath $stage) { + Remove-Item -LiteralPath $stage -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $stage | Out-Null + +Get-ChildItem -LiteralPath $source -Force | Where-Object { + $_.Name -notin @("node_modules", ".git") -and + $_.Extension -notin @(".pem", ".key", ".pfx", ".p12") -and + $_.Name -notlike ".env*" +} | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $stage $_.Name) -Recurse -Force +} + +@{ + schema = "flowmemory.bridge_evidence_export_manifest.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + sourceDir = $source + exclusions = @("node_modules", ".git", ".env*", "*.pem", "*.key", "*.pfx", "*.p12") + noSecrets = $true +} | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $stage "evidence-export-manifest.json") -Encoding UTF8 + +Assert-NoSecretText -Path $stage +if (Test-Path -LiteralPath $bundle) { + Remove-Item -LiteralPath $bundle -Force +} +Compress-Archive -Path (Join-Path $stage "*") -DestinationPath $bundle -Force +$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $bundle +$reportPath = Join-Path $exportRoot "base8453-bridge-evidence-export-report.json" +@{ + schema = "flowmemory.bridge_evidence_export_report.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + bundlePath = $bundle + bundleSha256 = $hash.Hash + sourceDir = $source + noSecrets = $true +} | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $reportPath -Encoding UTF8 + +Write-Host "Bridge evidence export complete." +Write-Host "Bundle: $bundle" +Write-Host "SHA256: $($hash.Hash)" +Write-Host "Report: $reportPath" diff --git a/infra/scripts/flowchain-no-secret-scan.mjs b/infra/scripts/flowchain-no-secret-scan.mjs new file mode 100644 index 00000000..8d722449 --- /dev/null +++ b/infra/scripts/flowchain-no-secret-scan.mjs @@ -0,0 +1,67 @@ +import { existsSync, readFileSync, statSync, readdirSync } from "node:fs"; +import { extname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); + +const scanTargets = [ + "devnet/local/live-base8453-relay", + "services/bridge-relayer/out", + "fixtures/bridge", +]; + +const textExtensions = new Set([".json", ".md", ".txt", ".ndjson"]); +const secretPatterns = [ + { reason: "labeled private key", pattern: /\b(private key|privkey|secret key)\b\s*[:=]\s*0x[0-9a-f]{64}\b/i }, + { reason: "http basic credential in URL", pattern: /\bhttps?:\/\/[^/\s:@]+:[^/\s:@]+@/i }, + { reason: "api token value", pattern: /\b(?:sk|pk|rk|ghp|gho|ghu|github_pat|xox[baprs])-[-_A-Za-z0-9]{16,}\b/ }, + { reason: "webhook URL", pattern: /\bhttps:\/\/(?:hooks\.slack\.com|discord(?:app)?\.com\/api\/webhooks|.*webhook)[^\s"']+/i }, + { reason: "private key block", pattern: /BEGIN (RSA |OPENSSH |EC )?PRIVATE KEY/i }, +]; + +function walk(path) { + const full = resolve(repoRoot, path); + if (!existsSync(full)) { + return []; + } + const item = statSync(full); + if (item.isFile()) { + return [full]; + } + const files = []; + for (const entry of readdirSync(full, { withFileTypes: true })) { + const child = join(full, entry.name); + if (entry.isDirectory()) { + files.push(...walk(child)); + } else if (entry.isFile()) { + files.push(child); + } + } + return files; +} + +const findings = []; +for (const target of scanTargets) { + for (const file of walk(target)) { + if (!textExtensions.has(extname(file).toLowerCase())) { + continue; + } + const body = readFileSync(file, "utf8"); + for (const check of secretPatterns) { + if (check.pattern.test(body)) { + findings.push({ file: file.replace(repoRoot, "").replace(/^[/\\]/, ""), reason: check.reason }); + } + } + } +} + +if (findings.length > 0) { + console.error(JSON.stringify({ status: "failed", findings }, null, 2)); + process.exit(1); +} + +console.log(JSON.stringify({ + status: "passed", + scannedTargets: scanTargets, + note: "No private key, RPC credential, API token, mnemonic, or webhook-shaped values found in bridge evidence artifacts.", +}, null, 2)); diff --git a/package.json b/package.json index d79d409c..455c2d67 100644 --- a/package.json +++ b/package.json @@ -68,10 +68,34 @@ "control-plane:serve": "npm run serve --prefix services/control-plane", "bridge:test": "npm test --prefix services/bridge-relayer", "bridge:mock": "npm run mock --prefix services/bridge-relayer", + "bridge:deploy:dry-run": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base8453-deploy.ps1 -Mode DryRun", + "bridge:deploy:base8453": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base8453-deploy.ps1 -Mode Broadcast", + "bridge:observe:mock": "npm run mock --prefix services/bridge-relayer", + "bridge:observe:base8453": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base8453-relay.ps1 -OperatorAck -Once", + "bridge:credit:local": "node services/bridge-relayer/src/observe-base-lockbox.ts --mode mock-pilot --fixture fixtures/bridge/base8453-pilot-mock-deposit.json --approved-lockbox 0x1111111111111111111111111111111111111111 --supported-token 0x3333333333333333333333333333333333333333 --confirmations 2 --acknowledge-pilot --max-usd 1 --max-deposit-amount 20000000 --total-cap-amount 20000000 --apply-credit --runtime-state services/bridge-relayer/out/bridge-credit-local-state.json --out services/bridge-relayer/out/bridge-credit-local-observation.json --credit-out services/bridge-relayer/out/bridge-credit-local-credit.json --handoff-out services/bridge-relayer/out/bridge-credit-local-handoff.json --evidence-out services/bridge-relayer/out/bridge-credit-local-evidence.json", + "bridge:credit:replay-check": "node services/bridge-relayer/src/bridge-pilot-e2e.ts --mode mock-pilot --fixture fixtures/bridge/base8453-pilot-mock-deposit.json --duplicate-fixture fixtures/bridge/base8453-pilot-duplicate-mock-deposits.json --out-dir services/bridge-relayer/out/real-value-pilot-e2e", + "bridge:withdraw:intent": "node services/bridge-relayer/src/observe-base-lockbox.ts --mode mock-pilot --fixture fixtures/bridge/base8453-pilot-mock-deposit.json --approved-lockbox 0x1111111111111111111111111111111111111111 --supported-token 0x3333333333333333333333333333333333333333 --confirmations 2 --acknowledge-pilot --max-usd 1 --max-deposit-amount 20000000 --total-cap-amount 20000000 --apply-credit --withdrawal-intent --out services/bridge-relayer/out/bridge-withdraw-observation.json --credit-out services/bridge-relayer/out/bridge-withdraw-credit.json --handoff-out services/bridge-relayer/out/bridge-withdraw-handoff.json --withdrawal-out services/bridge-relayer/out/bridge-withdrawal-intent.json --release-evidence-out services/bridge-relayer/out/bridge-release-evidence.json --evidence-out services/bridge-relayer/out/bridge-withdraw-evidence.json", + "bridge:release:evidence": "npm run bridge:withdraw:intent", + "bridge:pause": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base8453-control.ps1 -Action Pause", + "bridge:resume": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base8453-control.ps1 -Action Resume", + "bridge:emergency-stop": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base8453-control.ps1 -Action EmergencyStop", + "bridge:evidence:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-evidence-export.ps1", + "bridge:pilot:mock:e2e": "npm run pilot:e2e --prefix services/bridge-relayer", + "bridge:pilot:live:check": "node services/bridge-relayer/src/bridge-live-readiness-check.ts", + "bridge:relay:base8453": "npm run relay:base8453 --prefix services/bridge-relayer", + "bridge:relay:mock:e2e": "npm run relay:mock:e2e --prefix services/bridge-relayer", + "bridge:tx:diagnose": "npm run tx:diagnose --prefix services/bridge-relayer --", + "bridge:base8453:pilot:observe": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base8453-relay.ps1 -OperatorAck -Once", "bridge:sepolia:observe": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-sepolia-observe.ps1", "bridge:anvil:observe": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-local-anvil-observe.ps1", "bridge:local-credit:smoke": "npm run local-credit:smoke --prefix services/bridge-relayer", - "bridge:observe": "node services/bridge-relayer/src/observe-base-lockbox.ts" + "bridge:observe": "node services/bridge-relayer/src/observe-base-lockbox.ts", + "flowchain:real-value-pilot:contracts": "forge test --match-path tests/bridge/BaseBridgeLockbox.t.sol", + "flowchain:real-value-pilot:bridge": "npm run bridge:pilot:mock:e2e && npm run bridge:pilot:live:check", + "flowchain:real-value-pilot:runtime": "npm run bridge:local-credit:smoke && npm run bridge:pilot:mock:e2e", + "flowchain:bridge:mock:e2e": "npm run bridge:relay:mock:e2e", + "flowchain:bridge:live:check": "npm run bridge:pilot:live:check", + "flowchain:no-secret:scan": "node infra/scripts/flowchain-no-secret-scan.mjs" }, "devDependencies": { "ajv": "^8.20.0", diff --git a/schemas/flowmemory/bridge-local-usage-proof.schema.json b/schemas/flowmemory/bridge-local-usage-proof.schema.json new file mode 100644 index 00000000..185d7533 --- /dev/null +++ b/schemas/flowmemory/bridge-local-usage-proof.schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-local-usage-proof.schema.json", + "title": "FlowMemory Bridge Local Usage Proof", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "generatedAt", + "creditId", + "sourceRecipient", + "secondRecipient", + "transfer", + "balances", + "productOrDexFlow", + "localOnly", + "noSecrets" + ], + "properties": { + "schema": { + "const": "flowmemory.bridge_local_usage_proof.v0" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "creditId": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "sourceRecipient": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "secondRecipient": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "transfer": { + "type": "object", + "additionalProperties": false, + "required": [ + "transferId", + "amount", + "status", + "broadcast" + ], + "properties": { + "transferId": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "amount": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "status": { + "const": "prepared" + }, + "broadcast": { + "const": false + } + } + }, + "balances": { + "type": "object", + "additionalProperties": false, + "required": [ + "creditedWalletAfterCredit", + "creditedWalletAfterTransfer", + "secondWalletAfterTransfer" + ], + "properties": { + "creditedWalletAfterCredit": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "creditedWalletAfterTransfer": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "secondWalletAfterTransfer": { + "type": "string", + "pattern": "^[0-9]+$" + } + } + }, + "productOrDexFlow": { + "type": "object", + "additionalProperties": false, + "required": [ + "supportedByCommand", + "bridgeRelayerExecutesProductTrade", + "status" + ], + "properties": { + "supportedByCommand": { + "const": "npm run flowchain:product-e2e" + }, + "bridgeRelayerExecutesProductTrade": { + "const": false + }, + "status": { + "const": "covered_by_existing_local_gate" + } + } + }, + "localOnly": { + "const": true + }, + "noSecrets": { + "const": true + } + } +} diff --git a/schemas/flowmemory/bridge-observation.schema.json b/schemas/flowmemory/bridge-observation.schema.json index 0297b6cb..e81f23cf 100644 --- a/schemas/flowmemory/bridge-observation.schema.json +++ b/schemas/flowmemory/bridge-observation.schema.json @@ -19,7 +19,7 @@ "observationId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, "replayKey": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, "observedAt": { "type": "string" }, - "mode": { "type": "string", "enum": ["mock", "local-anvil", "base-sepolia", "base-mainnet-canary"] }, + "mode": { "type": "string", "enum": ["mock", "mock-pilot", "local-anvil", "base-sepolia", "base-mainnet-canary", "base-mainnet-pilot"] }, "productionReady": { "const": false }, "deposit": { "$ref": "bridge-deposit.schema.json" }, "guardrails": { @@ -31,7 +31,27 @@ "explicitContract": { "type": "boolean" }, "explicitBlockRange": { "type": "boolean" }, "noSecrets": { "type": "boolean" }, - "maxUsd": { "type": "number", "maximum": 25 } + "maxUsd": { "type": "number", "maximum": 25 }, + "maxDepositAmount": { "type": "string", "pattern": "^[0-9]+$" }, + "totalCapAmount": { "type": "string", "pattern": "^[0-9]+$" }, + "supportedTokens": { + "type": "array", + "items": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "uniqueItems": true + }, + "approvedContract": { "type": "boolean" }, + "confirmation": { + "type": "object", + "additionalProperties": false, + "required": ["depth", "satisfied"], + "properties": { + "depth": { "type": "integer", "minimum": 0 }, + "latestBlockNumber": { "type": "string", "pattern": "^[0-9]+$" }, + "requiredConfirmedBlockNumber": { "type": "string", "pattern": "^[0-9]+$" }, + "requestedToBlock": { "type": "string", "pattern": "^[0-9]+$" }, + "satisfied": { "type": "boolean" } + } + } } } } diff --git a/schemas/flowmemory/bridge-pilot-evidence.schema.json b/schemas/flowmemory/bridge-pilot-evidence.schema.json new file mode 100644 index 00000000..dcd6ec2a --- /dev/null +++ b/schemas/flowmemory/bridge-pilot-evidence.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-pilot-evidence.schema.json", + "title": "FlowChainBridgePilotEvidence", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "evidenceId", + "generatedAt", + "mode", + "productionReady", + "localOnly", + "observationId", + "creditId", + "depositId", + "replayKey", + "source", + "guardrails", + "creditApplication", + "replay", + "nextOperatorCommands" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_pilot_evidence.v0" }, + "evidenceId": { "$ref": "#/$defs/hex32" }, + "generatedAt": { "type": "string" }, + "mode": { "type": "string", "enum": ["mock-pilot", "base-mainnet-pilot"] }, + "productionReady": { "const": false }, + "localOnly": { "const": true }, + "observationId": { "$ref": "#/$defs/hex32" }, + "creditId": { "$ref": "#/$defs/hex32" }, + "depositId": { "$ref": "#/$defs/hex32" }, + "replayKey": { "$ref": "#/$defs/hex32" }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["chainId", "chainIdHex", "contract", "txHash", "logIndex"], + "properties": { + "chainId": { "const": 8453 }, + "chainIdHex": { "const": "0x2105" }, + "contract": { "$ref": "#/$defs/address" }, + "txHash": { "$ref": "#/$defs/hex32" }, + "logIndex": { "type": "integer", "minimum": 0 }, + "blockNumber": { "$ref": "#/$defs/uintString" } + } + }, + "guardrails": { + "type": "object", + "additionalProperties": false, + "required": ["approvedContract", "confirmation", "pilotModeTag", "operatorAcknowledged", "noSecrets"], + "properties": { + "approvedContract": { "const": true }, + "confirmation": { + "type": "object", + "additionalProperties": false, + "required": ["depth", "satisfied"], + "properties": { + "depth": { "type": "integer", "minimum": 0 }, + "latestBlockNumber": { "$ref": "#/$defs/uintString" }, + "requiredConfirmedBlockNumber": { "$ref": "#/$defs/uintString" }, + "requestedToBlock": { "$ref": "#/$defs/uintString" }, + "satisfied": { "const": true } + } + }, + "maxUsd": { "type": "number", "exclusiveMinimum": 0, "maximum": 25 }, + "maxDepositAmount": { "$ref": "#/$defs/uintString" }, + "totalCapAmount": { "$ref": "#/$defs/uintString" }, + "pilotModeTag": { "$ref": "#/$defs/hex32" }, + "supportedTokens": { + "type": "array", + "items": { "$ref": "#/$defs/address" }, + "uniqueItems": true + }, + "operatorAcknowledged": { "const": true }, + "noSecrets": { "const": true } + } + }, + "creditApplication": { + "type": "object", + "additionalProperties": false, + "required": ["status", "appliedExactlyOnce"], + "properties": { + "applicationId": { "$ref": "#/$defs/hex32" }, + "status": { "type": "string", "enum": ["applied", "idempotent_replay", "rejected", "pending"] }, + "appliedExactlyOnce": { "type": "boolean" }, + "rejectionReason": { "type": "string" } + } + }, + "replay": { + "type": "object", + "additionalProperties": false, + "required": ["decision", "duplicateReplayKeys"], + "properties": { + "decision": { "type": "string", "enum": ["accepted_once", "duplicate_replay_key_rejected", "already_applied_idempotent"] }, + "duplicateReplayKeys": { + "type": "array", + "items": { "$ref": "#/$defs/hex32" }, + "uniqueItems": true + } + } + }, + "nextOperatorCommands": { + "type": "array", + "items": { "type": "string" } + } + }, + "$defs": { + "address": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "hex32": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "uintString": { "type": "string", "pattern": "^[0-9]+$" } + } +} diff --git a/schemas/flowmemory/bridge-release-evidence.schema.json b/schemas/flowmemory/bridge-release-evidence.schema.json new file mode 100644 index 00000000..18e4625e --- /dev/null +++ b/schemas/flowmemory/bridge-release-evidence.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-release-evidence.schema.json", + "title": "FlowChainBridgeReleaseEvidence", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "releaseEvidenceId", + "generatedAt", + "withdrawalIntentId", + "creditId", + "depositId", + "sourceChainId", + "destinationChainId", + "lockbox", + "releaseCall", + "operatorNote", + "productionReady", + "localOnly" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_release_evidence.v0" }, + "releaseEvidenceId": { "$ref": "#/$defs/hex32" }, + "generatedAt": { "type": "string" }, + "withdrawalIntentId": { "$ref": "#/$defs/hex32" }, + "creditId": { "$ref": "#/$defs/hex32" }, + "depositId": { "$ref": "#/$defs/hex32" }, + "sourceChainId": { "type": "integer", "enum": [31337, 84532, 8453] }, + "destinationChainId": { "type": "integer", "enum": [31337, 84532, 8453] }, + "lockbox": { "$ref": "#/$defs/address" }, + "releaseCall": { + "type": "object", + "additionalProperties": false, + "required": ["method", "recipient", "token", "amount", "evidenceHash", "broadcast"], + "properties": { + "method": { "type": "string", "enum": ["releaseERC20", "releaseNative"] }, + "recipient": { "$ref": "#/$defs/address" }, + "token": { "$ref": "#/$defs/address" }, + "amount": { "$ref": "#/$defs/uintString" }, + "evidenceHash": { "$ref": "#/$defs/hex32" }, + "broadcast": { "const": false } + } + }, + "operatorNote": { "type": "string" }, + "productionReady": { "const": false }, + "localOnly": { "const": true } + }, + "$defs": { + "address": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "hex32": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "uintString": { "type": "string", "pattern": "^[0-9]+$" } + } +} diff --git a/schemas/flowmemory/bridge-runtime-credit-application-state.schema.json b/schemas/flowmemory/bridge-runtime-credit-application-state.schema.json new file mode 100644 index 00000000..3f2184cc --- /dev/null +++ b/schemas/flowmemory/bridge-runtime-credit-application-state.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-runtime-credit-application-state.schema.json", + "title": "FlowChainBridgeRuntimeCreditApplicationState", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "stateId", + "updatedAt", + "appliedReplayKeys", + "applications", + "localOnly", + "productionReady" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_runtime_credit_application_state.v0" }, + "stateId": { "$ref": "#/$defs/hex32" }, + "updatedAt": { "type": "string" }, + "appliedReplayKeys": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/hex32" }, + "propertyNames": { "pattern": "^0x[0-9a-fA-F]{64}$" } + }, + "applications": { + "type": "array", + "items": { "$ref": "bridge-runtime-credit-application.schema.json" } + }, + "localOnly": { "const": true }, + "productionReady": { "const": false } + }, + "$defs": { + "hex32": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" } + } +} diff --git a/schemas/flowmemory/bridge-runtime-credit-application.schema.json b/schemas/flowmemory/bridge-runtime-credit-application.schema.json new file mode 100644 index 00000000..935b2b01 --- /dev/null +++ b/schemas/flowmemory/bridge-runtime-credit-application.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-runtime-credit-application.schema.json", + "title": "FlowChainBridgeRuntimeCreditApplication", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "applicationId", + "creditId", + "depositId", + "replayKey", + "flowchainRecipient", + "amount", + "status", + "applyCount", + "localOnly", + "productionReady" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_runtime_credit_application.v0" }, + "applicationId": { "$ref": "#/$defs/hex32" }, + "creditId": { "$ref": "#/$defs/hex32" }, + "depositId": { "$ref": "#/$defs/hex32" }, + "replayKey": { "$ref": "#/$defs/hex32" }, + "flowchainRecipient": { "$ref": "#/$defs/hex32" }, + "amount": { "$ref": "#/$defs/uintString" }, + "status": { "type": "string", "enum": ["applied", "idempotent_replay", "rejected"] }, + "appliedAt": { "type": "string" }, + "previousApplicationId": { "$ref": "#/$defs/hex32" }, + "rejectionReason": { "type": "string" }, + "applyCount": { "type": "integer", "minimum": 0, "maximum": 1 }, + "localOnly": { "const": true }, + "productionReady": { "const": false } + }, + "$defs": { + "hex32": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "uintString": { "type": "string", "pattern": "^[0-9]+$" } + } +} diff --git a/schemas/flowmemory/bridge-runtime-handoff.schema.json b/schemas/flowmemory/bridge-runtime-handoff.schema.json index f464934e..47f794ef 100644 --- a/schemas/flowmemory/bridge-runtime-handoff.schema.json +++ b/schemas/flowmemory/bridge-runtime-handoff.schema.json @@ -24,7 +24,7 @@ "schema": { "const": "flowmemory.bridge_runtime_handoff.v0" }, "handoffId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, "generatedAt": { "type": "string" }, - "mode": { "type": "string", "enum": ["mock", "local-anvil", "base-sepolia", "base-mainnet-canary"] }, + "mode": { "type": "string", "enum": ["mock", "mock-pilot", "local-anvil", "base-sepolia", "base-mainnet-canary", "base-mainnet-pilot"] }, "productionReady": { "const": false }, "localOnly": { "const": true }, "observations": { @@ -39,6 +39,18 @@ "type": "array", "items": { "$ref": "bridge-withdrawal-intent.schema.json" } }, + "runtimeApplications": { + "type": "array", + "items": { "$ref": "bridge-runtime-credit-application.schema.json" } + }, + "pilotEvidence": { + "type": "array", + "items": { "$ref": "bridge-pilot-evidence.schema.json" } + }, + "releaseEvidences": { + "type": "array", + "items": { "$ref": "bridge-release-evidence.schema.json" } + }, "replayProtection": { "type": "object", "additionalProperties": false, diff --git a/schemas/flowmemory/bridge-withdrawal-authorization.schema.json b/schemas/flowmemory/bridge-withdrawal-authorization.schema.json new file mode 100644 index 00000000..da93ee08 --- /dev/null +++ b/schemas/flowmemory/bridge-withdrawal-authorization.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-withdrawal-authorization.schema.json", + "title": "FlowMemory Bridge Withdrawal Authorization", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "generatedAt", + "authorizationId", + "withdrawalIntentId", + "creditId", + "depositId", + "flowchainChainId", + "destinationChainId", + "token", + "amount", + "flowchainAccount", + "baseRecipient", + "withdrawalNonce", + "signedBy", + "signatureScheme", + "signedPayloadHash", + "signature", + "localOnly", + "noSecrets" + ], + "properties": { + "schema": { + "const": "flowmemory.bridge_withdrawal_authorization.v0" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "authorizationId": { + "$ref": "#/$defs/hex32" + }, + "withdrawalIntentId": { + "$ref": "#/$defs/hex32" + }, + "creditId": { + "$ref": "#/$defs/hex32" + }, + "depositId": { + "$ref": "#/$defs/hex32" + }, + "flowchainChainId": { + "const": "flowchain-local-pilot-v0" + }, + "destinationChainId": { + "type": "integer", + "enum": [ + 8453 + ] + }, + "token": { + "$ref": "#/$defs/address" + }, + "amount": { + "$ref": "#/$defs/uintString" + }, + "flowchainAccount": { + "$ref": "#/$defs/hex32" + }, + "baseRecipient": { + "$ref": "#/$defs/address" + }, + "withdrawalNonce": { + "$ref": "#/$defs/uintString" + }, + "signedBy": { + "$ref": "#/$defs/hex32" + }, + "signatureScheme": { + "const": "flowchain-pilot-deterministic-test-signature-v0" + }, + "signedPayloadHash": { + "$ref": "#/$defs/hex32" + }, + "signature": { + "$ref": "#/$defs/hex32" + }, + "localOnly": { + "const": true + }, + "noSecrets": { + "const": true + } + }, + "$defs": { + "address": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "hex32": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "uintString": { + "type": "string", + "pattern": "^[0-9]+$" + } + } +} diff --git a/services/bridge-relayer/package.json b/services/bridge-relayer/package.json index d6580122..bfe4176f 100644 --- a/services/bridge-relayer/package.json +++ b/services/bridge-relayer/package.json @@ -4,6 +4,10 @@ "type": "module", "scripts": { "mock": "node src/observe-base-lockbox.ts --mode mock --fixture ../../fixtures/bridge/base-sepolia-mock-deposit.json --out out/bridge-observation.json --credit-out out/bridge-credit.json --handoff-out out/bridge-runtime-handoff.json", + "pilot:e2e": "node src/bridge-pilot-e2e.ts --mode mock-pilot --fixture ../../fixtures/bridge/base8453-pilot-mock-deposit.json --duplicate-fixture ../../fixtures/bridge/base8453-pilot-duplicate-mock-deposits.json --out-dir out/real-value-pilot-e2e", + "relay:mock:e2e": "node src/base8453-relay-mock-e2e.ts", + "relay:base8453": "node src/base8453-relay-monitor.ts --monitor", + "tx:diagnose": "node src/base8453-tx-diagnostic.ts", "local-credit:smoke": "node src/observe-base-lockbox.ts --mode mock --fixture ../../fixtures/bridge/base-sepolia-mock-deposit.json --out out/local-credit-observation.json --credit-out out/local-credit.json --handoff-out ../../fixtures/bridge/local-runtime-bridge-handoff.json --withdrawal-out out/local-withdrawal-intent.json --apply-credit --withdrawal-intent", "test": "node --test test/*.test.ts" } diff --git a/services/bridge-relayer/src/base8453-relay-mock-e2e.ts b/services/bridge-relayer/src/base8453-relay-mock-e2e.ts new file mode 100644 index 00000000..36e77d95 --- /dev/null +++ b/services/bridge-relayer/src/base8453-relay-mock-e2e.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { assertNoSecrets } from "../../shared/src/index.ts"; +import { + DEFAULT_CONFIRMATIONS, + DEFAULT_POLL_MS, + runRelayOnce, + type RelayMonitorOptions, +} from "./base8453-relay-monitor.ts"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const OUT_DIR = "devnet/local/live-base8453-relay"; +const FIXTURE = "fixtures/bridge/base8453-pilot-mock-deposit.json"; +const APPROVED_LOCKBOX = "0x1111111111111111111111111111111111111111"; +const SUPPORTED_TOKEN = "0x3333333333333333333333333333333333333333"; + +function repoPath(path: string): string { + return resolve(REPO_ROOT, path); +} + +function relativeToRepo(path: string): string { + return relative(REPO_ROOT, resolve(path)).replace(/\\/g, "/"); +} + +function writeJson(path: string, value: unknown): void { + const outPath = repoPath(path); + mkdirSync(dirname(outPath), { recursive: true }); + assertNoSecrets(value); + writeFileSync(outPath, `${JSON.stringify(value, null, 2)}\n`); +} + +function options(): RelayMonitorOptions { + return { + mode: "mock-pilot", + fixturePath: repoPath(FIXTURE), + lockboxAddress: APPROVED_LOCKBOX, + approvedLockboxAddress: APPROVED_LOCKBOX, + supportedTokens: [SUPPORTED_TOKEN], + latestBlockOverride: "112", + confirmations: DEFAULT_CONFIRMATIONS, + pollMs: DEFAULT_POLL_MS, + maxScanBlocks: 500n, + recoveryWindowBlocks: 128n, + maxUsd: "1", + maxDepositAmount: "20000000", + totalCapAmount: "40000000", + checkpointPath: `${OUT_DIR}/checkpoint.json`, + statusOutPath: `${OUT_DIR}/status.json`, + reportOutPath: `${OUT_DIR}/relay-report.json`, + handoffOutPath: `${OUT_DIR}/bridge-runtime-handoff.json`, + runtimeStatePath: `${OUT_DIR}/relay-credit-application-state.json`, + nodeStatePath: `${OUT_DIR}/mock-node-state.json`, + nodeDir: `${OUT_DIR}/mock-node`, + nodeSubmitMode: "direct", + nodeWaitMs: 5_000, + monitor: false, + iterations: 1, + acknowledgePilot: true, + acknowledgeRealFunds: true, + }; +} + +const outPath = repoPath(OUT_DIR); +rmSync(outPath, { recursive: true, force: true }); +mkdirSync(outPath, { recursive: true }); + +const firstRun = await runRelayOnce(options()); +assert.equal(firstRun.status.overallStatus, "READY"); +assert.equal(firstRun.status.summary.creditAppliedToRunningL1Node, 1); +assert.equal(firstRun.report.counts.applied, 1); +assert.equal(firstRun.report.counts.idempotent, 0); +assert.ok(firstRun.report.firstSpendableTimestamp, "first run must produce a spendable timestamp"); + +const replayRun = await runRelayOnce(options()); +assert.equal(replayRun.status.overallStatus, "READY"); +assert.equal(replayRun.status.previousReadyPreserved, true); +assert.equal(replayRun.status.summary.duplicateIdempotentReplay, 1); +assert.equal(replayRun.report.counts.applied, 0); +assert.equal(replayRun.report.counts.idempotent, 1); +assert.equal(replayRun.status.summary.invalidDirectTransferOrReverted, 0); + +const report = { + schema: "flowmemory.base8453_low_latency_mock_e2e_report.v0", + generatedAt: new Date().toISOString(), + status: "passed", + confirmationDepth: DEFAULT_CONFIRMATIONS, + pollMs: DEFAULT_POLL_MS, + firstRun: { + status: firstRun.status.overallStatus, + latestBaseBlock: firstRun.report.latestBaseBlock, + scanFrom: firstRun.report.scanFrom, + scanTo: firstRun.report.scanTo, + applied: firstRun.report.counts.applied, + idempotent: firstRun.report.counts.idempotent, + firstSpendableTimestamp: firstRun.report.firstSpendableTimestamp, + }, + replayRun: { + status: replayRun.status.overallStatus, + previousReadyPreserved: replayRun.status.previousReadyPreserved, + applied: replayRun.report.counts.applied, + idempotent: replayRun.report.counts.idempotent, + invalid: replayRun.report.counts.invalid, + }, + artifacts: { + checkpoint: relativeToRepo(repoPath(`${OUT_DIR}/checkpoint.json`)), + status: relativeToRepo(repoPath(`${OUT_DIR}/status.json`)), + relayReport: relativeToRepo(repoPath(`${OUT_DIR}/relay-report.json`)), + handoff: relativeToRepo(repoPath(`${OUT_DIR}/bridge-runtime-handoff.json`)), + runtimeState: relativeToRepo(repoPath(`${OUT_DIR}/relay-credit-application-state.json`)), + nodeState: relativeToRepo(repoPath(`${OUT_DIR}/mock-node-state.json`)), + }, + releaseBroadcast: false, + noSecrets: true, +}; + +writeJson(`${OUT_DIR}/mock-e2e-report.json`, report); +console.log(`Base 8453 low-latency mock relay E2E passed: ${relativeToRepo(repoPath(`${OUT_DIR}/mock-e2e-report.json`))}`); diff --git a/services/bridge-relayer/src/base8453-relay-monitor.ts b/services/bridge-relayer/src/base8453-relay-monitor.ts new file mode 100644 index 00000000..bfabb430 --- /dev/null +++ b/services/bridge-relayer/src/base8453-relay-monitor.ts @@ -0,0 +1,1236 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { assertNoSecrets } from "../../shared/src/index.ts"; +import { + BASE_MAINNET_CHAIN_ID, + BASE_MAINNET_CHAIN_ID_HEX, + MAX_BLOCK_RANGE, + ZERO_ADDRESS, + fixtureDeposits, + makeBridgeCredit, + makeRuntimeHandoff, + parseBridgeArgs, + readLatestBlockNumber, + runBridgePipeline, + type BridgeMode, + type BridgeObservation, + type BridgePipelineResult, + type BridgeRuntimeCreditApplication, + type BridgeRuntimeHandoff, +} from "./observe-base-lockbox.ts"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +export const LIVE_BASE8453_LOCKBOX = "0xe731Bc6b117d92deDCA40a7ccAec11d16205026a".toLowerCase() as `0x${string}`; +export const DEFAULT_RELAY_DIR = "devnet/local/live-base8453-relay"; +export const DEFAULT_CONFIRMATIONS = 12; +export const DEFAULT_POLL_MS = 5_000; +export const DEFAULT_MAX_SCAN_BLOCKS = 500n; +export const DEFAULT_RECOVERY_WINDOW_BLOCKS = 128n; +export const DEFAULT_NODE_WAIT_MS = 60_000; +export const PILOT_ACK_VALUE = "I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT"; + +type RelayMode = Extract; +type NodeSubmitMode = "inbox" | "direct" | "off"; + +export type RelayDepositLifecycleStatus = + | "no_deposits_observed" + | "deposit_observed_not_confirmation_eligible" + | "deposit_observed_and_queued" + | "credit_applied_to_running_l1_node" + | "duplicate_idempotent_replay" + | "invalid_direct_transfer" + | "invalid_reverted_transaction" + | "invalid_wrong_contract" + | "invalid_missing_bridge_deposit_event"; + +export type RelayOverallStatus = + | "NO_DEPOSITS_OBSERVED" + | "OBSERVED_PENDING_CONFIRMATIONS" + | "OBSERVED_QUEUED" + | "READY" + | "IDEMPOTENT_REPLAY" + | "INVALID_OR_REVERTED"; + +export interface RelayMonitorOptions { + mode: RelayMode; + rpcUrl?: string; + fixturePath?: string; + lockboxAddress: `0x${string}`; + approvedLockboxAddress: `0x${string}`; + supportedTokens: `0x${string}`[]; + startBlock?: string; + latestBlockOverride?: string; + confirmations: number; + pollMs: number; + maxScanBlocks: bigint; + recoveryWindowBlocks: bigint; + maxUsd: string; + maxDepositAmount: string; + totalCapAmount: string; + checkpointPath: string; + statusOutPath: string; + reportOutPath: string; + handoffOutPath: string; + runtimeStatePath: string; + nodeStatePath: string; + nodeDir: string; + nodeSubmitMode: NodeSubmitMode; + nodeWaitMs: number; + monitor: boolean; + iterations: number; + acknowledgePilot: boolean; + acknowledgeRealFunds: boolean; +} + +interface RelayCheckpoint { + schema: "flowmemory.base8453_live_relay_checkpoint.v0"; + updatedAt: string; + chainId: typeof BASE_MAINNET_CHAIN_ID; + lockbox: `0x${string}`; + confirmationDepth: number; + maxScanBlocks: string; + recoveryWindowBlocks: string; + lastLatestBlock: string; + lastConfirmedScanFrom: string | null; + lastConfirmedScanTo: string | null; + nextConfirmedScanFrom: string | null; + observedReplayKeys: `0x${string}`[]; + appliedReplayKeys: `0x${string}`[]; + idempotentReplayKeys: `0x${string}`[]; + statusPath: string; + handoffPath: string; + noSecrets: true; +} + +interface RelayDepositStatusRow { + txHash?: `0x${string}`; + logIndex?: number; + depositId?: `0x${string}`; + replayKey?: `0x${string}`; + sourceBlockNumber?: string; + status: RelayDepositLifecycleStatus; + detail: string; +} + +interface NodeCreditIngestResult { + creditId: `0x${string}`; + applicationId: `0x${string}`; + depositId: `0x${string}`; + replayKey: `0x${string}`; + accountId: `0x${string}`; + amount: string; + status: "queued_to_running_node_inbox" | "direct_applied_to_local_node_state" | "spendable" | "not_submitted" | "failed"; + queuedTxIds: string[]; + commandStatus?: number | null; + error?: string; + firstSpendableAt?: string; + creditedAtBlock?: string; +} + +interface NodeIngestSummary { + schema: "flowmemory.base8453_live_relay_node_ingest.v0"; + mode: NodeSubmitMode; + nodeStatePath: string; + nodeDir: string; + submittedCount: number; + spendableCount: number; + firstSpendableAt?: string; + results: NodeCreditIngestResult[]; + noSecrets: true; +} + +export interface RelayStatusFile { + schema: "flowmemory.base8453_live_relay_status.v0"; + generatedAt: string; + overallStatus: RelayOverallStatus; + reason: string; + mode: RelayMode; + sourceChain: { + chainId: typeof BASE_MAINNET_CHAIN_ID; + chainIdHex: typeof BASE_MAINNET_CHAIN_ID_HEX; + }; + lockbox: `0x${string}`; + latestBaseBlock: string; + scanFrom: string | null; + scanTo: string | null; + pendingScanFrom: string | null; + pendingScanTo: string | null; + confirmationDepth: number; + pollMs: number; + maxScanBlocks: string; + recoveryWindowBlocks: string; + checkpointPath: string; + handoffPath: string; + runtimeStatePath: string; + summary: { + noDepositsObserved: boolean; + observedNotConfirmationEligible: number; + observedQueued: number; + creditAppliedToRunningL1Node: number; + duplicateIdempotentReplay: number; + invalidDirectTransferOrReverted: number; + appliedRelayCredits: number; + }; + deposits: RelayDepositStatusRow[]; + nodeIngest: NodeIngestSummary; + releaseBroadcast: false; + previousReadyPreserved: boolean; + noSecrets: true; +} + +export interface RelayReport { + schema: "flowmemory.base8453_live_relay_report.v0"; + generatedAt: string; + status: RelayOverallStatus; + latestBaseBlock: string; + scanFrom: string | null; + scanTo: string | null; + confirmationDepth: number; + deposits: RelayDepositStatusRow[]; + counts: { + applied: number; + idempotent: number; + queued: number; + pendingConfirmations: number; + invalid: number; + }; + handoffPath: string; + runningL1IngestResult: NodeIngestSummary; + firstSpendableTimestamp?: string; + releaseBroadcast: false; + noSecrets: true; +} + +export interface RelayRunResult { + checkpoint: RelayCheckpoint; + status: RelayStatusFile; + report: RelayReport; + handoff: BridgeRuntimeHandoff; + confirmedPipeline: BridgePipelineResult; + pendingObservations: BridgeObservation[]; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function repoPath(path: string): string { + return resolve(REPO_ROOT, path); +} + +function relativeToRepo(path: string): string { + return relative(REPO_ROOT, resolve(path)).replace(/\\/g, "/"); +} + +function maybeReadJson(path: string): T | null { + const resolved = repoPath(path); + return existsSync(resolved) ? JSON.parse(readFileSync(resolved, "utf8")) as T : null; +} + +function writeJson(path: string, value: unknown): void { + const outPath = repoPath(path); + mkdirSync(dirname(outPath), { recursive: true }); + assertNoSecrets(value); + writeFileSync(outPath, `${JSON.stringify(value, null, 2)}\n`); +} + +function env(name: string): string | undefined { + const value = process.env[name]; + return value === undefined || value.trim() === "" ? undefined : value; +} + +function argValue(args: string[], index: number, name: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return value; +} + +function asPositiveInteger(value: string, name: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +function asNonNegativeBlock(value: string, name: string): bigint { + if (!/^[0-9]+$/.test(value)) { + throw new Error(`${name} must be a decimal block number`); + } + return BigInt(value); +} + +function asAddress(value: string, name: string): `0x${string}` { + if (!/^0x[0-9a-fA-F]{40}$/.test(value)) { + throw new Error(`${name} must be a 20-byte hex address`); + } + return value.toLowerCase() as `0x${string}`; +} + +function parseAddressList(value: string, name: string): `0x${string}`[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => asAddress(entry, name)); +} + +function parseBigIntOption(value: string | undefined, fallback: bigint, name: string): bigint { + if (value === undefined) { + return fallback; + } + const parsed = asNonNegativeBlock(value, name); + if (parsed === 0n) { + throw new Error(`${name} must be greater than zero`); + } + return parsed; +} + +function loadCheckpoint(path: string): RelayCheckpoint | null { + const checkpoint = maybeReadJson(path); + if (checkpoint === null) { + return null; + } + if (checkpoint.schema !== "flowmemory.base8453_live_relay_checkpoint.v0") { + throw new Error("unsupported relay checkpoint schema"); + } + return checkpoint; +} + +function loadPreviousStatus(path: string): RelayStatusFile | null { + const status = maybeReadJson(path); + if (status === null) { + return null; + } + if (status.schema !== "flowmemory.base8453_live_relay_status.v0") { + return null; + } + return status; +} + +function previousWasReady(status: RelayStatusFile | null): boolean { + return status?.overallStatus === "READY" || (status?.summary.creditAppliedToRunningL1Node ?? 0) > 0; +} + +function uniqueObservations(observations: BridgeObservation[]): BridgeObservation[] { + const seen = new Set(); + const unique: BridgeObservation[] = []; + for (const observation of observations) { + const key = `${observation.replayKey}:${observation.deposit.txHash}:${observation.deposit.logIndex}`; + if (!seen.has(key)) { + seen.add(key); + unique.push(observation); + } + } + return unique; +} + +function emptyPipeline(mode: RelayMode, handoffPath: string): BridgePipelineResult { + const handoff = makeRuntimeHandoff(mode, [], [], [], [], [], [], handoffPath); + return { + observations: [], + credits: [], + withdrawalIntents: [], + runtimeApplications: [], + pilotEvidence: [], + releaseEvidences: [], + handoff, + }; +} + +function pipelineArgsForRange(options: RelayMonitorOptions, fromBlock: bigint, toBlock: bigint): string[] { + return [ + "--mode", + "base-mainnet-pilot", + "--rpc-url", + options.rpcUrl ?? "", + "--lockbox-address", + options.lockboxAddress, + "--approved-lockbox", + options.approvedLockboxAddress, + ...options.supportedTokens.flatMap((token) => ["--supported-token", token]), + "--from-block", + fromBlock.toString(), + "--to-block", + toBlock.toString(), + "--confirmations", + options.confirmations.toString(), + "--acknowledge-pilot", + "--acknowledge-real-funds", + "--max-usd", + options.maxUsd, + "--max-deposit-amount", + options.maxDepositAmount, + "--total-cap-amount", + options.totalCapAmount, + "--apply-credit", + "--runtime-state", + repoPath(options.runtimeStatePath), + ]; +} + +function pendingArgsForRange(options: RelayMonitorOptions, fromBlock: bigint, toBlock: bigint): string[] { + return [ + "--mode", + "base-mainnet-canary", + "--rpc-url", + options.rpcUrl ?? "", + "--lockbox-address", + options.lockboxAddress, + "--approved-lockbox", + options.approvedLockboxAddress, + ...options.supportedTokens.flatMap((token) => ["--supported-token", token]), + "--from-block", + fromBlock.toString(), + "--to-block", + toBlock.toString(), + "--acknowledge-real-funds", + "--max-usd", + options.maxUsd, + ]; +} + +async function runConfirmedPipeline( + options: RelayMonitorOptions, + scanFrom: bigint | null, + scanTo: bigint | null, +): Promise { + if (options.mode === "mock-pilot") { + return runBridgePipeline(parseBridgeArgs([ + "--mode", + "mock-pilot", + "--fixture", + options.fixturePath ?? "", + "--approved-lockbox", + options.approvedLockboxAddress, + ...options.supportedTokens.flatMap((token) => ["--supported-token", token]), + "--confirmations", + options.confirmations.toString(), + "--acknowledge-pilot", + "--max-usd", + options.maxUsd, + "--max-deposit-amount", + options.maxDepositAmount, + "--total-cap-amount", + options.totalCapAmount, + "--apply-credit", + "--runtime-state", + repoPath(options.runtimeStatePath), + ])); + } + if (scanFrom === null || scanTo === null || scanTo < scanFrom) { + return emptyPipeline(options.mode, options.handoffOutPath); + } + return runBridgePipeline(parseBridgeArgs(pipelineArgsForRange(options, scanFrom, scanTo))); +} + +async function readPendingObservations( + options: RelayMonitorOptions, + pendingFrom: bigint | null, + pendingTo: bigint | null, +): Promise { + if (options.mode === "mock-pilot") { + return []; + } + if (pendingFrom === null || pendingTo === null || pendingTo < pendingFrom) { + return []; + } + const pending = await runBridgePipeline(parseBridgeArgs(pendingArgsForRange(options, pendingFrom, pendingTo))); + return pending.observations; +} + +function fixtureLatestBlock(options: RelayMonitorOptions): bigint { + if (options.latestBlockOverride !== undefined) { + return asNonNegativeBlock(options.latestBlockOverride, "--latest-block"); + } + const deposits = fixtureDeposits(JSON.parse(readFileSync(repoPath(options.fixturePath ?? ""), "utf8"))); + const maxFixtureBlock = deposits.reduce((max, deposit) => { + const block = deposit.sourceBlockNumber === undefined ? 0n : BigInt(deposit.sourceBlockNumber); + return block > max ? block : max; + }, 0n); + return maxFixtureBlock + BigInt(options.confirmations); +} + +async function latestBaseBlock(options: RelayMonitorOptions): Promise { + if (options.mode === "mock-pilot") { + return fixtureLatestBlock(options); + } + return readLatestBlockNumber(options.rpcUrl ?? ""); +} + +interface ScanWindow { + latestBlock: bigint; + confirmedTo: bigint | null; + scanFrom: bigint | null; + scanTo: bigint | null; + pendingFrom: bigint | null; + pendingTo: bigint | null; +} + +function boundedWidth(from: bigint, to: bigint): bigint { + return to >= from ? to - from + 1n : 0n; +} + +function determineWindow(options: RelayMonitorOptions, checkpoint: RelayCheckpoint | null, latestBlock: bigint): ScanWindow { + const confirmationDepth = BigInt(options.confirmations); + const startBlock = options.startBlock !== undefined + ? asNonNegativeBlock(options.startBlock, "--from-block") + : latestBlock > options.recoveryWindowBlocks + ? latestBlock - options.recoveryWindowBlocks + 1n + : 0n; + const confirmedTo = latestBlock >= confirmationDepth ? latestBlock - confirmationDepth : null; + if (confirmedTo === null || confirmedTo < startBlock) { + return { + latestBlock, + confirmedTo, + scanFrom: null, + scanTo: null, + pendingFrom: startBlock <= latestBlock ? startBlock : null, + pendingTo: startBlock <= latestBlock ? latestBlock : null, + }; + } + + const checkpointTo = checkpoint?.lastConfirmedScanTo === null || checkpoint?.lastConfirmedScanTo === undefined + ? null + : BigInt(checkpoint.lastConfirmedScanTo); + const recoveryFloor = confirmedTo > options.recoveryWindowBlocks + ? confirmedTo - options.recoveryWindowBlocks + 1n + : 0n; + const checkpointFloor = checkpointTo === null + ? recoveryFloor + : checkpointTo > options.recoveryWindowBlocks + ? checkpointTo - options.recoveryWindowBlocks + 1n + : 0n; + let scanFrom = [startBlock, recoveryFloor, checkpointFloor].reduce((max, value) => value > max ? value : max, 0n); + const scanTo = confirmedTo; + if (boundedWidth(scanFrom, scanTo) > options.maxScanBlocks) { + scanFrom = scanTo - options.maxScanBlocks + 1n; + } + if (boundedWidth(scanFrom, scanTo) > MAX_BLOCK_RANGE) { + throw new Error(`relay scan range exceeds hard max ${MAX_BLOCK_RANGE.toString()} blocks`); + } + + const pendingFrom = confirmedTo + 1n <= latestBlock ? confirmedTo + 1n : null; + let boundedPendingFrom = pendingFrom; + if (pendingFrom !== null && boundedWidth(pendingFrom, latestBlock) > options.maxScanBlocks) { + boundedPendingFrom = latestBlock - options.maxScanBlocks + 1n; + } + + return { + latestBlock, + confirmedTo, + scanFrom, + scanTo, + pendingFrom: boundedPendingFrom, + pendingTo: boundedPendingFrom === null ? null : latestBlock, + }; +} + +function applicationByCredit( + applications: BridgeRuntimeCreditApplication[], +): Map<`0x${string}`, BridgeRuntimeCreditApplication> { + return new Map(applications.map((application) => [application.creditId, application])); +} + +function readNodeState(path: string): Record | null { + return maybeReadJson>(path); +} + +function creditIsSpendable(application: BridgeRuntimeCreditApplication, state: Record | null): { + spendable: boolean; + creditedAtBlock?: string; +} { + if (state === null) { + return { spendable: false }; + } + const records = state.faucetRecords; + if (records !== null && typeof records === "object" && !Array.isArray(records)) { + for (const value of Object.values(records as Record>)) { + if (String(value.reason ?? "").includes(application.applicationId)) { + return { + spendable: true, + creditedAtBlock: value.creditedAtBlock === undefined ? undefined : String(value.creditedAtBlock), + }; + } + } + } + const balances = state.localTestUnitBalances; + if (balances !== null && typeof balances === "object" && !Array.isArray(balances)) { + const balance = (balances as Record>)[application.flowchainRecipient]; + if (balance !== undefined && BigInt(String(balance.units ?? "0")) >= BigInt(application.amount)) { + return { spendable: true, creditedAtBlock: balance.updatedAtBlock === undefined ? undefined : String(balance.updatedAtBlock) }; + } + } + return { spendable: false }; +} + +async function waitForSpendable( + applications: BridgeRuntimeCreditApplication[], + nodeStatePath: string, + waitMs: number, +): Promise> { + const spendable = new Map<`0x${string}`, { firstSpendableAt: string; creditedAtBlock?: string }>(); + if (applications.length === 0 || waitMs <= 0) { + return spendable; + } + const deadline = Date.now() + waitMs; + while (Date.now() <= deadline) { + const state = readNodeState(nodeStatePath); + for (const application of applications) { + if (spendable.has(application.applicationId)) { + continue; + } + const result = creditIsSpendable(application, state); + if (result.spendable) { + spendable.set(application.applicationId, { + firstSpendableAt: nowIso(), + creditedAtBlock: result.creditedAtBlock, + }); + } + } + if (spendable.size === applications.length) { + return spendable; + } + await new Promise((resolveSleep) => setTimeout(resolveSleep, 1_000)); + } + return spendable; +} + +function runCargo(args: string[]): { status: number | null; stdout: string; stderr: string } { + const targetDir = repoPath(`devnet/local/cargo-target/bridge-relay-${process.pid}`); + mkdirSync(targetDir, { recursive: true }); + const result = spawnSync("cargo", args, { + cwd: REPO_ROOT, + encoding: "utf8", + env: { + ...process.env, + CARGO_TARGET_DIR: targetDir, + }, + }); + if (result.error !== undefined) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout || `cargo exited ${result.status}`); + } + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +function queuedTxIds(stdout: string): string[] { + try { + const payload = JSON.parse(stdout) as { queued?: unknown }; + return Array.isArray(payload.queued) ? payload.queued.map(String) : []; + } catch { + return []; + } +} + +function assertAmountFitsU64(amount: string): void { + const parsed = BigInt(amount); + if (parsed <= 0n || parsed > 18_446_744_073_709_551_615n) { + throw new Error(`credit amount cannot be queued to local node as u64 test units: ${amount}`); + } +} + +async function submitApplicationsToNode( + options: RelayMonitorOptions, + applications: BridgeRuntimeCreditApplication[], +): Promise { + const applied = applications.filter((application) => application.status === "applied"); + const results: NodeCreditIngestResult[] = []; + if (options.nodeSubmitMode === "off" || applied.length === 0) { + return { + schema: "flowmemory.base8453_live_relay_node_ingest.v0", + mode: options.nodeSubmitMode, + nodeStatePath: relativeToRepo(repoPath(options.nodeStatePath)), + nodeDir: relativeToRepo(repoPath(options.nodeDir)), + submittedCount: 0, + spendableCount: 0, + results: applied.map((application) => ({ + creditId: application.creditId, + applicationId: application.applicationId, + depositId: application.depositId, + replayKey: application.replayKey, + accountId: application.flowchainRecipient, + amount: application.amount, + status: "not_submitted", + queuedTxIds: [], + })), + noSecrets: true, + }; + } + + for (const application of applied) { + try { + assertAmountFitsU64(application.amount); + const reason = `base8453-bridge-credit:${application.applicationId}`; + const args = [ + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + repoPath(options.nodeStatePath), + "--node-dir", + repoPath(options.nodeDir), + "faucet", + "--account", + application.flowchainRecipient, + "--amount", + application.amount, + "--reason", + reason, + "--authorized-by", + "bridge-relayer:base8453", + ]; + if (options.nodeSubmitMode === "direct") { + args.push("--direct"); + } + const queued = runCargo(args); + if (options.nodeSubmitMode === "direct") { + runCargo([ + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + repoPath(options.nodeStatePath), + "--node-dir", + repoPath(options.nodeDir), + "start", + "--blocks", + "1", + ]); + } + results.push({ + creditId: application.creditId, + applicationId: application.applicationId, + depositId: application.depositId, + replayKey: application.replayKey, + accountId: application.flowchainRecipient, + amount: application.amount, + status: options.nodeSubmitMode === "direct" ? "direct_applied_to_local_node_state" : "queued_to_running_node_inbox", + queuedTxIds: queuedTxIds(queued.stdout), + commandStatus: queued.status, + }); + } catch (error) { + results.push({ + creditId: application.creditId, + applicationId: application.applicationId, + depositId: application.depositId, + replayKey: application.replayKey, + accountId: application.flowchainRecipient, + amount: application.amount, + status: "failed", + queuedTxIds: [], + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const spendable = await waitForSpendable(applied, options.nodeStatePath, options.nodeWaitMs); + for (const result of results) { + const ready = spendable.get(result.applicationId); + if (ready !== undefined) { + result.status = "spendable"; + result.firstSpendableAt = ready.firstSpendableAt; + result.creditedAtBlock = ready.creditedAtBlock; + } + } + + const firstSpendableAt = results + .map((result) => result.firstSpendableAt) + .filter((value): value is string => value !== undefined) + .sort()[0]; + return { + schema: "flowmemory.base8453_live_relay_node_ingest.v0", + mode: options.nodeSubmitMode, + nodeStatePath: relativeToRepo(repoPath(options.nodeStatePath)), + nodeDir: relativeToRepo(repoPath(options.nodeDir)), + submittedCount: results.filter((result) => result.status !== "not_submitted" && result.status !== "failed").length, + spendableCount: results.filter((result) => result.status === "spendable").length, + firstSpendableAt, + results, + noSecrets: true, + }; +} + +function depositRows( + confirmedPipeline: BridgePipelineResult, + pendingObservations: BridgeObservation[], + nodeIngest: NodeIngestSummary, +): RelayDepositStatusRow[] { + const appByCredit = applicationByCredit(confirmedPipeline.runtimeApplications); + const nodeByApplication = new Map(nodeIngest.results.map((result) => [result.applicationId, result])); + const rows: RelayDepositStatusRow[] = []; + for (const credit of confirmedPipeline.credits) { + const observation = confirmedPipeline.observations.find((candidate) => candidate.replayKey === credit.replayKey); + const application = appByCredit.get(credit.creditId); + const nodeResult = application === undefined ? undefined : nodeByApplication.get(application.applicationId); + let status: RelayDepositLifecycleStatus = "deposit_observed_and_queued"; + let detail = "Deposit is confirmation-eligible and queued for local runtime credit."; + if (application?.status === "idempotent_replay") { + status = "duplicate_idempotent_replay"; + detail = "Replay key was already applied; duplicate read was treated as idempotent."; + } else if (credit.rejectionReason === "duplicate_replay_key" || credit.rejectionReason === "already_applied_replay_key") { + status = "duplicate_idempotent_replay"; + detail = `Duplicate credit rejected without erasing prior application: ${credit.rejectionReason}.`; + } else if (nodeResult?.status === "spendable") { + status = "credit_applied_to_running_l1_node"; + detail = "Credit was observed in local FlowChain node state."; + } + rows.push({ + txHash: credit.source.txHash, + logIndex: credit.source.logIndex, + depositId: credit.depositId, + replayKey: credit.replayKey, + sourceBlockNumber: observation?.deposit.sourceBlockNumber, + status, + detail, + }); + } + for (const observation of pendingObservations) { + rows.push({ + txHash: observation.deposit.txHash, + logIndex: observation.deposit.logIndex, + depositId: observation.deposit.depositId, + replayKey: observation.replayKey, + sourceBlockNumber: observation.deposit.sourceBlockNumber, + status: "deposit_observed_not_confirmation_eligible", + detail: `Deposit is observed but waiting for ${observation.guardrails.confirmation?.depth ?? DEFAULT_CONFIRMATIONS} confirmations.`, + }); + } + return rows; +} + +function overallStatus(rows: RelayDepositStatusRow[], nodeIngest: NodeIngestSummary, previousReady: boolean): { + status: RelayOverallStatus; + reason: string; + previousReadyPreserved: boolean; +} { + const invalid = rows.filter((row) => row.status.startsWith("invalid_")).length; + const spendable = rows.filter((row) => row.status === "credit_applied_to_running_l1_node").length; + const queued = rows.filter((row) => row.status === "deposit_observed_and_queued").length; + const pending = rows.filter((row) => row.status === "deposit_observed_not_confirmation_eligible").length; + const idempotent = rows.filter((row) => row.status === "duplicate_idempotent_replay").length; + if (invalid > 0) { + return { status: "INVALID_OR_REVERTED", reason: "Invalid, direct-transfer, wrong-contract, or reverted transaction observed.", previousReadyPreserved: false }; + } + if (spendable > 0 || nodeIngest.spendableCount > 0) { + return { status: "READY", reason: "At least one credit is spendable in the local FlowChain node state.", previousReadyPreserved: false }; + } + if (previousReady && idempotent > 0) { + return { status: "READY", reason: "Duplicate replay was idempotent and prior READY result was preserved.", previousReadyPreserved: true }; + } + if (queued > 0) { + return { status: "OBSERVED_QUEUED", reason: "A confirmation-eligible deposit was observed and queued for node ingest.", previousReadyPreserved: false }; + } + if (pending > 0) { + return { status: "OBSERVED_PENDING_CONFIRMATIONS", reason: "A deposit was observed but is not confirmation-eligible yet.", previousReadyPreserved: false }; + } + if (idempotent > 0) { + return { status: "IDEMPOTENT_REPLAY", reason: "Only duplicate/idempotent replay reads were observed.", previousReadyPreserved: false }; + } + return { status: "NO_DEPOSITS_OBSERVED", reason: "No BridgeDeposit events were observed in the bounded relay window.", previousReadyPreserved: false }; +} + +function makeCheckpoint( + options: RelayMonitorOptions, + window: ScanWindow, + rows: RelayDepositStatusRow[], + status: RelayStatusFile, +): RelayCheckpoint { + return { + schema: "flowmemory.base8453_live_relay_checkpoint.v0", + updatedAt: status.generatedAt, + chainId: BASE_MAINNET_CHAIN_ID, + lockbox: options.lockboxAddress, + confirmationDepth: options.confirmations, + maxScanBlocks: options.maxScanBlocks.toString(), + recoveryWindowBlocks: options.recoveryWindowBlocks.toString(), + lastLatestBlock: window.latestBlock.toString(), + lastConfirmedScanFrom: window.scanFrom?.toString() ?? null, + lastConfirmedScanTo: window.scanTo?.toString() ?? null, + nextConfirmedScanFrom: window.scanTo === null ? null : (window.scanTo + 1n).toString(), + observedReplayKeys: rows + .map((row) => row.replayKey) + .filter((value): value is `0x${string}` => value !== undefined) + .sort(), + appliedReplayKeys: rows + .filter((row) => row.status === "credit_applied_to_running_l1_node" || row.status === "deposit_observed_and_queued") + .map((row) => row.replayKey) + .filter((value): value is `0x${string}` => value !== undefined) + .sort(), + idempotentReplayKeys: rows + .filter((row) => row.status === "duplicate_idempotent_replay") + .map((row) => row.replayKey) + .filter((value): value is `0x${string}` => value !== undefined) + .sort(), + statusPath: relativeToRepo(repoPath(options.statusOutPath)), + handoffPath: relativeToRepo(repoPath(options.handoffOutPath)), + noSecrets: true, + }; +} + +function makeStatusAndReport( + options: RelayMonitorOptions, + window: ScanWindow, + rows: RelayDepositStatusRow[], + nodeIngest: NodeIngestSummary, + previousStatus: RelayStatusFile | null, +): { status: RelayStatusFile; report: RelayReport } { + const generatedAt = nowIso(); + const state = overallStatus(rows, nodeIngest, previousWasReady(previousStatus)); + const pending = rows.filter((row) => row.status === "deposit_observed_not_confirmation_eligible").length; + const queued = rows.filter((row) => row.status === "deposit_observed_and_queued").length; + const ready = rows.filter((row) => row.status === "credit_applied_to_running_l1_node").length; + const idempotent = rows.filter((row) => row.status === "duplicate_idempotent_replay").length; + const invalid = rows.filter((row) => row.status.startsWith("invalid_")).length; + const status: RelayStatusFile = { + schema: "flowmemory.base8453_live_relay_status.v0", + generatedAt, + overallStatus: state.status, + reason: state.reason, + mode: options.mode, + sourceChain: { + chainId: BASE_MAINNET_CHAIN_ID, + chainIdHex: BASE_MAINNET_CHAIN_ID_HEX, + }, + lockbox: options.lockboxAddress, + latestBaseBlock: window.latestBlock.toString(), + scanFrom: window.scanFrom?.toString() ?? null, + scanTo: window.scanTo?.toString() ?? null, + pendingScanFrom: window.pendingFrom?.toString() ?? null, + pendingScanTo: window.pendingTo?.toString() ?? null, + confirmationDepth: options.confirmations, + pollMs: options.pollMs, + maxScanBlocks: options.maxScanBlocks.toString(), + recoveryWindowBlocks: options.recoveryWindowBlocks.toString(), + checkpointPath: relativeToRepo(repoPath(options.checkpointPath)), + handoffPath: relativeToRepo(repoPath(options.handoffOutPath)), + runtimeStatePath: relativeToRepo(repoPath(options.runtimeStatePath)), + summary: { + noDepositsObserved: rows.length === 0, + observedNotConfirmationEligible: pending, + observedQueued: queued, + creditAppliedToRunningL1Node: ready, + duplicateIdempotentReplay: idempotent, + invalidDirectTransferOrReverted: invalid, + appliedRelayCredits: nodeIngest.results.length, + }, + deposits: rows, + nodeIngest, + releaseBroadcast: false, + previousReadyPreserved: state.previousReadyPreserved, + noSecrets: true, + }; + const report: RelayReport = { + schema: "flowmemory.base8453_live_relay_report.v0", + generatedAt, + status: status.overallStatus, + latestBaseBlock: status.latestBaseBlock, + scanFrom: status.scanFrom, + scanTo: status.scanTo, + confirmationDepth: status.confirmationDepth, + deposits: rows, + counts: { + applied: queued + ready, + idempotent, + queued, + pendingConfirmations: pending, + invalid, + }, + handoffPath: status.handoffPath, + runningL1IngestResult: nodeIngest, + firstSpendableTimestamp: nodeIngest.firstSpendableAt, + releaseBroadcast: false, + noSecrets: true, + }; + return { status, report }; +} + +function writeCombinedHandoff( + options: RelayMonitorOptions, + confirmedPipeline: BridgePipelineResult, + pendingObservations: BridgeObservation[], +): BridgeRuntimeHandoff { + const allObservations = uniqueObservations([...confirmedPipeline.observations, ...pendingObservations]); + const confirmedReplayKeys = new Set(confirmedPipeline.credits.map((credit) => credit.replayKey)); + const pendingCredits = pendingObservations + .filter((observation) => !confirmedReplayKeys.has(observation.replayKey)) + .map((observation) => makeBridgeCredit(observation, "pending")); + const handoff = makeRuntimeHandoff( + options.mode, + allObservations, + [...confirmedPipeline.credits, ...pendingCredits], + confirmedPipeline.withdrawalIntents, + confirmedPipeline.runtimeApplications, + confirmedPipeline.pilotEvidence, + confirmedPipeline.releaseEvidences, + options.handoffOutPath, + ); + writeJson(options.handoffOutPath, handoff); + return handoff; +} + +export async function runRelayOnce(options: RelayMonitorOptions): Promise { + if (options.maxScanBlocks > MAX_BLOCK_RANGE) { + throw new Error(`--max-scan-blocks cannot exceed ${MAX_BLOCK_RANGE.toString()}`); + } + if (options.recoveryWindowBlocks > options.maxScanBlocks) { + throw new Error("--recovery-window-blocks must be less than or equal to --max-scan-blocks"); + } + if (options.mode === "base-mainnet-pilot" && (!options.acknowledgePilot || !options.acknowledgeRealFunds)) { + throw new Error(`Base 8453 relay requires ${PILOT_ACK_VALUE} acknowledgement or explicit acknowledgement flags`); + } + + const checkpoint = loadCheckpoint(options.checkpointPath); + const previousStatus = loadPreviousStatus(options.statusOutPath); + const latestBlock = await latestBaseBlock(options); + const window = determineWindow(options, checkpoint, latestBlock); + const confirmedPipeline = await runConfirmedPipeline(options, window.scanFrom, window.scanTo); + const pendingObservations = await readPendingObservations(options, window.pendingFrom, window.pendingTo); + const handoff = writeCombinedHandoff(options, confirmedPipeline, pendingObservations); + const nodeIngest = await submitApplicationsToNode(options, confirmedPipeline.runtimeApplications); + const rows = depositRows(confirmedPipeline, pendingObservations, nodeIngest); + const { status, report } = makeStatusAndReport(options, window, rows, nodeIngest, previousStatus); + const nextCheckpoint = makeCheckpoint(options, window, rows, status); + + writeJson(options.statusOutPath, status); + writeJson(options.reportOutPath, report); + writeJson(options.checkpointPath, nextCheckpoint); + + return { + checkpoint: nextCheckpoint, + status, + report, + handoff, + confirmedPipeline, + pendingObservations, + }; +} + +export function parseRelayArgs(args: string[]): RelayMonitorOptions { + let mode: RelayMode = "base-mainnet-pilot"; + let fixturePath: string | undefined; + let rpcUrl = env("FLOWCHAIN_BASE8453_RPC_URL"); + let lockboxAddress = asAddress(env("FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS") ?? LIVE_BASE8453_LOCKBOX, "--lockbox-address"); + let approvedLockboxAddress = asAddress(env("FLOWCHAIN_BASE8453_APPROVED_LOCKBOX_ADDRESS") ?? env("FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS") ?? LIVE_BASE8453_LOCKBOX, "--approved-lockbox"); + let supportedTokens = parseAddressList(env("FLOWCHAIN_BASE8453_SUPPORTED_TOKEN") ?? ZERO_ADDRESS, "--supported-token"); + let supportedTokensExplicit = env("FLOWCHAIN_BASE8453_SUPPORTED_TOKEN") !== undefined; + let startBlock = env("FLOWCHAIN_BASE8453_FROM_BLOCK"); + let latestBlockOverride: string | undefined; + let confirmations = asPositiveInteger(env("FLOWCHAIN_PILOT_CONFIRMATIONS") ?? String(DEFAULT_CONFIRMATIONS), "--confirmations"); + let pollMs = asPositiveInteger(env("FLOWCHAIN_BASE8453_RELAY_POLL_MS") ?? String(DEFAULT_POLL_MS), "--poll-ms"); + let maxScanBlocks = parseBigIntOption(env("FLOWCHAIN_BASE8453_RELAY_MAX_SCAN_BLOCKS"), DEFAULT_MAX_SCAN_BLOCKS, "--max-scan-blocks"); + let recoveryWindowBlocks = parseBigIntOption(env("FLOWCHAIN_BASE8453_RELAY_RECOVERY_WINDOW_BLOCKS"), DEFAULT_RECOVERY_WINDOW_BLOCKS, "--recovery-window-blocks"); + let maxUsd = env("FLOWCHAIN_PILOT_MAX_USD") ?? "1"; + let maxDepositAmount = env("FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI") ?? ""; + let totalCapAmount = env("FLOWCHAIN_PILOT_TOTAL_CAP_WEI") ?? ""; + let checkpointPath = `${DEFAULT_RELAY_DIR}/checkpoint.json`; + let statusOutPath = `${DEFAULT_RELAY_DIR}/status.json`; + let reportOutPath = `${DEFAULT_RELAY_DIR}/relay-report.json`; + let handoffOutPath = `${DEFAULT_RELAY_DIR}/bridge-runtime-handoff.json`; + let runtimeStatePath = `${DEFAULT_RELAY_DIR}/relay-credit-application-state.json`; + let nodeStatePath = "devnet/local/state.json"; + let nodeDir = "devnet/local/node"; + let nodeSubmitMode: NodeSubmitMode = "inbox"; + let nodeWaitMs = DEFAULT_NODE_WAIT_MS; + let monitor = false; + let iterations = 1; + let acknowledgePilot = env("FLOWCHAIN_PILOT_OPERATOR_ACK") === PILOT_ACK_VALUE; + let acknowledgeRealFunds = env("FLOWCHAIN_PILOT_OPERATOR_ACK") === PILOT_ACK_VALUE; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--mode") { + const value = argValue(args, index, arg); + if (value !== "mock-pilot" && value !== "base-mainnet-pilot") { + throw new Error("--mode must be mock-pilot or base-mainnet-pilot"); + } + mode = value; + index += 1; + } else if (arg === "--fixture") { + fixturePath = argValue(args, index, arg); + index += 1; + } else if (arg === "--rpc-url") { + rpcUrl = argValue(args, index, arg); + index += 1; + } else if (arg === "--lockbox-address") { + lockboxAddress = asAddress(argValue(args, index, arg), arg); + index += 1; + } else if (arg === "--approved-lockbox") { + approvedLockboxAddress = asAddress(argValue(args, index, arg), arg); + index += 1; + } else if (arg === "--supported-token") { + if (!supportedTokensExplicit) { + supportedTokens = []; + supportedTokensExplicit = true; + } + supportedTokens.push(asAddress(argValue(args, index, arg), arg)); + index += 1; + } else if (arg === "--supported-tokens") { + supportedTokens = parseAddressList(argValue(args, index, arg), arg); + supportedTokensExplicit = true; + index += 1; + } else if (arg === "--from-block" || arg === "--start-block") { + startBlock = argValue(args, index, arg); + index += 1; + } else if (arg === "--latest-block") { + latestBlockOverride = argValue(args, index, arg); + index += 1; + } else if (arg === "--confirmations") { + confirmations = asPositiveInteger(argValue(args, index, arg), arg); + index += 1; + } else if (arg === "--poll-ms") { + pollMs = asPositiveInteger(argValue(args, index, arg), arg); + index += 1; + } else if (arg === "--max-scan-blocks") { + maxScanBlocks = parseBigIntOption(argValue(args, index, arg), DEFAULT_MAX_SCAN_BLOCKS, arg); + index += 1; + } else if (arg === "--recovery-window-blocks") { + recoveryWindowBlocks = parseBigIntOption(argValue(args, index, arg), DEFAULT_RECOVERY_WINDOW_BLOCKS, arg); + index += 1; + } else if (arg === "--max-usd") { + maxUsd = argValue(args, index, arg); + index += 1; + } else if (arg === "--max-deposit-amount") { + maxDepositAmount = argValue(args, index, arg); + index += 1; + } else if (arg === "--total-cap-amount") { + totalCapAmount = argValue(args, index, arg); + index += 1; + } else if (arg === "--checkpoint") { + checkpointPath = argValue(args, index, arg); + index += 1; + } else if (arg === "--status-out") { + statusOutPath = argValue(args, index, arg); + index += 1; + } else if (arg === "--report-out") { + reportOutPath = argValue(args, index, arg); + index += 1; + } else if (arg === "--handoff-out") { + handoffOutPath = argValue(args, index, arg); + index += 1; + } else if (arg === "--runtime-state") { + runtimeStatePath = argValue(args, index, arg); + index += 1; + } else if (arg === "--node-state") { + nodeStatePath = argValue(args, index, arg); + index += 1; + } else if (arg === "--node-dir") { + nodeDir = argValue(args, index, arg); + index += 1; + } else if (arg === "--node-mode") { + const value = argValue(args, index, arg); + if (value !== "inbox" && value !== "direct" && value !== "off") { + throw new Error("--node-mode must be inbox, direct, or off"); + } + nodeSubmitMode = value; + index += 1; + } else if (arg === "--no-node-submit") { + nodeSubmitMode = "off"; + } else if (arg === "--node-wait-ms") { + nodeWaitMs = asPositiveInteger(argValue(args, index, arg), arg); + index += 1; + } else if (arg === "--monitor") { + monitor = true; + iterations = 0; + } else if (arg === "--once") { + monitor = false; + iterations = 1; + } else if (arg === "--iterations") { + iterations = asPositiveInteger(argValue(args, index, arg), arg); + monitor = iterations !== 1; + index += 1; + } else if (arg === "--acknowledge-pilot") { + acknowledgePilot = true; + } else if (arg === "--acknowledge-real-funds") { + acknowledgeRealFunds = true; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + + if (mode === "mock-pilot" && fixturePath === undefined) { + throw new Error("--fixture is required in mock-pilot relay mode"); + } + if (mode === "base-mainnet-pilot" && (rpcUrl === undefined || rpcUrl.trim() === "")) { + throw new Error("FLOWCHAIN_BASE8453_RPC_URL or --rpc-url is required for Base 8453 relay mode"); + } + if (maxDepositAmount === "" || totalCapAmount === "") { + throw new Error("FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI and FLOWCHAIN_PILOT_TOTAL_CAP_WEI are required for relay credit caps"); + } + if (pollMs > DEFAULT_POLL_MS) { + throw new Error(`--poll-ms must be ${DEFAULT_POLL_MS} or lower for the pilot relay`); + } + + return { + mode, + rpcUrl, + fixturePath, + lockboxAddress, + approvedLockboxAddress, + supportedTokens: [...new Set(supportedTokens)].sort() as `0x${string}`[], + startBlock, + latestBlockOverride, + confirmations, + pollMs, + maxScanBlocks, + recoveryWindowBlocks, + maxUsd, + maxDepositAmount, + totalCapAmount, + checkpointPath, + statusOutPath, + reportOutPath, + handoffOutPath, + runtimeStatePath, + nodeStatePath, + nodeDir, + nodeSubmitMode, + nodeWaitMs, + monitor, + iterations, + acknowledgePilot: mode === "mock-pilot" ? true : acknowledgePilot, + acknowledgeRealFunds: mode === "mock-pilot" ? true : acknowledgeRealFunds, + }; +} + +async function runRelayCli(options: RelayMonitorOptions): Promise { + console.log(`Base 8453 relay mode: ${options.mode}`); + console.log(`Lockbox: ${options.lockboxAddress}`); + console.log(`Confirmation depth: ${options.confirmations}`); + console.log(`Polling interval: ${options.pollMs}ms`); + console.log(`Max scan blocks: ${options.maxScanBlocks.toString()}`); + console.log("Broadcast: false; release transactions are never sent by this relay."); + + let completed = 0; + for (;;) { + const result = await runRelayOnce(options); + completed += 1; + console.log( + `Relay cycle complete: status=${result.status.overallStatus}, scan=${result.status.scanFrom ?? "none"}-${result.status.scanTo ?? "none"}, applied=${result.report.counts.applied}, idempotent=${result.report.counts.idempotent}`, + ); + if (!options.monitor && completed >= options.iterations) { + break; + } + if (options.monitor && options.iterations > 0 && completed >= options.iterations) { + break; + } + await new Promise((resolveSleep) => setTimeout(resolveSleep, options.pollMs)); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + await runRelayCli(parseRelayArgs(process.argv.slice(2))); +} diff --git a/services/bridge-relayer/src/base8453-tx-diagnostic.ts b/services/bridge-relayer/src/base8453-tx-diagnostic.ts new file mode 100644 index 00000000..3639cfe1 --- /dev/null +++ b/services/bridge-relayer/src/base8453-tx-diagnostic.ts @@ -0,0 +1,359 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { assertNoSecrets } from "../../shared/src/index.ts"; +import { + BASE_MAINNET_CHAIN_ID, + BASE_MAINNET_CHAIN_ID_HEX, + BRIDGE_DEPOSIT_TOPIC0, + ZERO_ADDRESS, + parseBridgeDepositLog, + readChainId, + rpcCall, + type BridgeDeposit, +} from "./observe-base-lockbox.ts"; +import { LIVE_BASE8453_LOCKBOX, PILOT_ACK_VALUE } from "./base8453-relay-monitor.ts"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +export const LOCK_NATIVE_SELECTOR = "0x1326d1ec"; + +type DiagnosticClassification = + | "valid_bridge_deposit" + | "pending_or_missing_receipt" + | "reverted_transaction" + | "wrong_contract" + | "wrong_method_or_direct_transfer" + | "missing_bridge_deposit_event" + | "cap_failure" + | "wrong_chain"; + +interface RpcReceipt { + status?: string; + to?: string; + transactionHash: string; + blockNumber?: string; + logs?: { + address: string; + topics: string[]; + data: string; + blockNumber?: string; + blockHash?: string; + transactionHash: string; + transactionIndex?: string; + logIndex: string; + removed?: boolean; + }[]; +} + +interface RpcTransaction { + to?: string | null; + input?: string; + value?: string; + hash?: string; +} + +interface DiagnosticOptions { + rpcUrl: string; + txHash: `0x${string}`; + approvedLockbox: `0x${string}`; + supportedTokens: `0x${string}`[]; + maxDepositAmount?: string; + totalCapAmount?: string; + outPath: string; + acknowledgePilot: boolean; +} + +export interface TxDiagnosticReport { + schema: "flowmemory.base8453_tx_diagnostic.v0"; + generatedAt: string; + sourceChain: { + chainId: typeof BASE_MAINNET_CHAIN_ID; + chainIdHex: typeof BASE_MAINNET_CHAIN_ID_HEX; + }; + txHash: `0x${string}`; + approvedLockbox: `0x${string}`; + receipt: { + exists: boolean; + status: "success" | "reverted" | "missing"; + }; + checks: { + chainIsBase8453: boolean; + recipientIsApprovedLockbox: boolean; + methodSelectorIsLockNative: boolean; + bridgeDepositEventExists: boolean; + capOk: boolean; + tokenSupported: boolean; + }; + classification: DiagnosticClassification; + explanation: string; + deposit?: Pick; + noSecrets: true; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function env(name: string): string | undefined { + const value = process.env[name]; + return value === undefined || value.trim() === "" ? undefined : value; +} + +function argValue(args: string[], index: number, name: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return value; +} + +function asHash(value: string, name: string): `0x${string}` { + if (!/^0x[0-9a-fA-F]{64}$/.test(value)) { + throw new Error(`${name} must be a 32-byte hex value`); + } + return value.toLowerCase() as `0x${string}`; +} + +function asAddress(value: string, name: string): `0x${string}` { + if (!/^0x[0-9a-fA-F]{40}$/.test(value)) { + throw new Error(`${name} must be a 20-byte hex address`); + } + return value.toLowerCase() as `0x${string}`; +} + +function parseAddressList(value: string, name: string): `0x${string}`[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => asAddress(entry, name)); +} + +function writeJson(path: string, value: unknown): void { + const outPath = resolve(REPO_ROOT, path); + mkdirSync(dirname(outPath), { recursive: true }); + assertNoSecrets(value); + writeFileSync(outPath, `${JSON.stringify(value, null, 2)}\n`); +} + +function selector(input: string | undefined): string { + if (input === undefined || input === "0x" || input.length < 10) { + return "0x"; + } + return input.slice(0, 10).toLowerCase(); +} + +function txRecipient(tx: RpcTransaction | null, receipt: RpcReceipt | null): string | null { + return (tx?.to ?? receipt?.to ?? null)?.toLowerCase() ?? null; +} + +function capOk(deposit: BridgeDeposit | undefined, options: DiagnosticOptions): boolean { + if (deposit === undefined) { + return true; + } + if (options.maxDepositAmount !== undefined && BigInt(deposit.amount) > BigInt(options.maxDepositAmount)) { + return false; + } + if (options.totalCapAmount !== undefined && BigInt(deposit.amount) > BigInt(options.totalCapAmount)) { + return false; + } + return true; +} + +function tokenSupported(deposit: BridgeDeposit | undefined, options: DiagnosticOptions): boolean { + if (deposit === undefined || options.supportedTokens.length === 0) { + return true; + } + const supported = new Set(options.supportedTokens.map((token) => token.toLowerCase())); + return supported.has(deposit.token.toLowerCase()); +} + +function classify( + chainOk: boolean, + receipt: RpcReceipt | null, + recipientOk: boolean, + selectorOk: boolean, + eventExists: boolean, + capAllowed: boolean, +): { classification: DiagnosticClassification; explanation: string } { + if (!chainOk) { + return { + classification: "wrong_chain", + explanation: "RPC endpoint is not Base 8453; no bridge credit should be derived from this diagnostic.", + }; + } + if (receipt === null) { + return { + classification: "pending_or_missing_receipt", + explanation: "Receipt is missing; the transaction is pending, unknown, or pruned by the endpoint.", + }; + } + if (receipt.status !== "0x1") { + return { + classification: "reverted_transaction", + explanation: "Receipt status is not successful; reverted transactions do not create bridge credits.", + }; + } + if (!recipientOk) { + return { + classification: "wrong_contract", + explanation: "Transaction recipient is not the approved lockbox.", + }; + } + if (!selectorOk) { + return { + classification: "wrong_method_or_direct_transfer", + explanation: "Transaction did not call lockNative(bytes32,bytes32); direct ETH sends or wrong selectors are not bridge deposits.", + }; + } + if (!eventExists) { + return { + classification: "missing_bridge_deposit_event", + explanation: "Transaction reached the lockbox selector but did not emit BridgeDeposit.", + }; + } + if (!capAllowed) { + return { + classification: "cap_failure", + explanation: "BridgeDeposit exists, but amount or token is outside configured pilot caps.", + }; + } + return { + classification: "valid_bridge_deposit", + explanation: "Receipt succeeded, recipient is the approved lockbox, selector is lockNative, and BridgeDeposit exists.", + }; +} + +export async function diagnoseTx(options: DiagnosticOptions): Promise { + if (!options.acknowledgePilot) { + throw new Error(`Base 8453 transaction diagnostics require operator acknowledgement ${PILOT_ACK_VALUE}`); + } + + const chainId = await readChainId(options.rpcUrl); + const chainOk = chainId === BASE_MAINNET_CHAIN_ID; + const receipt = await rpcCall(options.rpcUrl, "eth_getTransactionReceipt", [options.txHash]); + const tx = await rpcCall(options.rpcUrl, "eth_getTransactionByHash", [options.txHash]); + const recipient = txRecipient(tx, receipt); + const recipientOk = recipient === options.approvedLockbox.toLowerCase(); + const selectorOk = selector(tx?.input) === LOCK_NATIVE_SELECTOR; + const bridgeLogs = (receipt?.logs ?? []).filter((log) => { + return log.address.toLowerCase() === options.approvedLockbox.toLowerCase() + && log.topics[0]?.toLowerCase() === BRIDGE_DEPOSIT_TOPIC0.toLowerCase(); + }); + const deposits = bridgeLogs.map((log) => parseBridgeDepositLog(log, BASE_MAINNET_CHAIN_ID)); + const deposit = deposits[0]; + const capAllowed = capOk(deposit, options) && tokenSupported(deposit, options); + const decision = classify(chainOk, receipt, recipientOk, selectorOk, deposits.length > 0, capAllowed); + + return { + schema: "flowmemory.base8453_tx_diagnostic.v0", + generatedAt: nowIso(), + sourceChain: { + chainId: BASE_MAINNET_CHAIN_ID, + chainIdHex: BASE_MAINNET_CHAIN_ID_HEX, + }, + txHash: options.txHash, + approvedLockbox: options.approvedLockbox, + receipt: { + exists: receipt !== null, + status: receipt === null ? "missing" : receipt.status === "0x1" ? "success" : "reverted", + }, + checks: { + chainIsBase8453: chainOk, + recipientIsApprovedLockbox: recipientOk, + methodSelectorIsLockNative: selectorOk, + bridgeDepositEventExists: deposits.length > 0, + capOk: capAllowed, + tokenSupported: tokenSupported(deposit, options), + }, + classification: decision.classification, + explanation: decision.explanation, + deposit: deposit === undefined ? undefined : { + depositId: deposit.depositId, + txHash: deposit.txHash, + logIndex: deposit.logIndex, + sourceBlockNumber: deposit.sourceBlockNumber, + token: deposit.token, + amount: deposit.amount, + flowchainRecipient: deposit.flowchainRecipient, + }, + noSecrets: true, + }; +} + +export function parseDiagnosticArgs(args: string[]): DiagnosticOptions { + let rpcUrl = env("FLOWCHAIN_BASE8453_RPC_URL") ?? ""; + let txHash: `0x${string}` | undefined; + let approvedLockbox = asAddress(env("FLOWCHAIN_BASE8453_APPROVED_LOCKBOX_ADDRESS") ?? env("FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS") ?? LIVE_BASE8453_LOCKBOX, "--approved-lockbox"); + let supportedTokens = parseAddressList(env("FLOWCHAIN_BASE8453_SUPPORTED_TOKEN") ?? ZERO_ADDRESS, "--supported-token"); + let supportedTokensExplicit = env("FLOWCHAIN_BASE8453_SUPPORTED_TOKEN") !== undefined; + let maxDepositAmount = env("FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI"); + let totalCapAmount = env("FLOWCHAIN_PILOT_TOTAL_CAP_WEI"); + let outPath = "devnet/local/live-base8453-relay/tx-diagnostic.json"; + let acknowledgePilot = env("FLOWCHAIN_PILOT_OPERATOR_ACK") === PILOT_ACK_VALUE; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--rpc-url") { + rpcUrl = argValue(args, index, arg); + index += 1; + } else if (arg === "--tx" || arg === "--tx-hash") { + txHash = asHash(argValue(args, index, arg), arg); + index += 1; + } else if (arg === "--approved-lockbox" || arg === "--lockbox-address") { + approvedLockbox = asAddress(argValue(args, index, arg), arg); + index += 1; + } else if (arg === "--supported-token") { + if (!supportedTokensExplicit) { + supportedTokens = []; + supportedTokensExplicit = true; + } + supportedTokens.push(asAddress(argValue(args, index, arg), arg)); + index += 1; + } else if (arg === "--supported-tokens") { + supportedTokens = parseAddressList(argValue(args, index, arg), arg); + supportedTokensExplicit = true; + index += 1; + } else if (arg === "--max-deposit-amount") { + maxDepositAmount = argValue(args, index, arg); + index += 1; + } else if (arg === "--total-cap-amount") { + totalCapAmount = argValue(args, index, arg); + index += 1; + } else if (arg === "--out") { + outPath = argValue(args, index, arg); + index += 1; + } else if (arg === "--acknowledge-pilot") { + acknowledgePilot = true; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + + if (rpcUrl.trim() === "") { + throw new Error("FLOWCHAIN_BASE8453_RPC_URL or --rpc-url is required for tx diagnostics"); + } + if (txHash === undefined) { + throw new Error("--tx-hash is required for tx diagnostics"); + } + + return { + rpcUrl, + txHash, + approvedLockbox, + supportedTokens: [...new Set(supportedTokens)].sort() as `0x${string}`[], + maxDepositAmount, + totalCapAmount, + outPath, + acknowledgePilot, + }; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const options = parseDiagnosticArgs(process.argv.slice(2)); + const report = await diagnoseTx(options); + writeJson(options.outPath, report); + console.log(`Base 8453 tx diagnostic: ${report.classification}`); + console.log(report.explanation); +} diff --git a/services/bridge-relayer/src/bridge-live-readiness-check.ts b/services/bridge-relayer/src/bridge-live-readiness-check.ts new file mode 100644 index 00000000..1c9ed75f --- /dev/null +++ b/services/bridge-relayer/src/bridge-live-readiness-check.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { assertNoSecrets } from "../../shared/src/index.ts"; +import { parseBridgeArgs, runBridgePipeline } from "./observe-base-lockbox.ts"; + +const REQUIRED_ENV = [ + "FLOWCHAIN_BASE8453_RPC_URL", + "FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS", + "FLOWCHAIN_BASE8453_SUPPORTED_TOKEN", + "FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI", + "FLOWCHAIN_PILOT_TOTAL_CAP_WEI", + "FLOWCHAIN_PILOT_CONFIRMATIONS", + "FLOWCHAIN_PILOT_OPERATOR_ACK", +] as const; + +const OPTIONAL_LIVE_ENV = [ + "FLOWCHAIN_BASE8453_FROM_BLOCK", + "FLOWCHAIN_BASE8453_TO_BLOCK", + "FLOWCHAIN_PILOT_MAX_USD", +] as const; + +const REQUIRED_ACK = "I_UNDERSTAND_THIS_IS_CAPPED_BASE8453_OWNER_PILOT"; +const DEFAULT_OUT = "services/bridge-relayer/out/base8453-live-readiness-check.json"; + +function env(name: string): string | undefined { + const value = process.env[name]; + return value === undefined || value.trim() === "" ? undefined : value; +} + +function baseLiveArgs(): string[] { + return [ + "--mode", + "base-mainnet-pilot", + "--rpc-url", + env("FLOWCHAIN_BASE8453_RPC_URL") ?? "", + "--lockbox-address", + env("FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS") ?? "", + "--approved-lockbox", + env("FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS") ?? "", + "--supported-token", + env("FLOWCHAIN_BASE8453_SUPPORTED_TOKEN") ?? "", + "--from-block", + env("FLOWCHAIN_BASE8453_FROM_BLOCK") ?? "", + "--to-block", + env("FLOWCHAIN_BASE8453_TO_BLOCK") ?? "", + "--confirmations", + env("FLOWCHAIN_PILOT_CONFIRMATIONS") ?? "", + "--acknowledge-pilot", + "--acknowledge-real-funds", + "--max-usd", + env("FLOWCHAIN_PILOT_MAX_USD") ?? "1", + "--max-deposit-amount", + env("FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI") ?? "", + "--total-cap-amount", + env("FLOWCHAIN_PILOT_TOTAL_CAP_WEI") ?? "", + ]; +} + +function writeJson(path: string, value: unknown): void { + const outPath = resolve(path); + mkdirSync(dirname(outPath), { recursive: true }); + assertNoSecrets(value); + writeFileSync(outPath, `${JSON.stringify(value, null, 2)}\n`); + console.log(`Wrote ${outPath}`); +} + +async function expectRejects(name: string, fn: () => unknown | Promise, pattern: RegExp): Promise { + try { + await Promise.resolve(fn()); + } catch (error) { + assert.match(error instanceof Error ? error.message : String(error), pattern); + return name; + } + assert.fail(`Expected rejection for ${name}`); + return name; +} + +async function selfTest(): Promise> { + const missingEnv = REQUIRED_ENV.filter((name) => env(name) === undefined); + const simulatedMissingEnv = [...REQUIRED_ENV]; + + const common = [ + "--mode", + "base-mainnet-pilot", + "--rpc-url", + "https://example.invalid/base", + "--lockbox-address", + "0x1111111111111111111111111111111111111111", + "--approved-lockbox", + "0x1111111111111111111111111111111111111111", + "--supported-token", + "0x3333333333333333333333333333333333333333", + "--from-block", + "100", + "--to-block", + "100", + "--confirmations", + "2", + "--acknowledge-pilot", + "--acknowledge-real-funds", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + ]; + + const missingAck = await expectRejects( + "missing operator acknowledgement rejected", + () => parseBridgeArgs(common.filter((arg) => arg !== "--acknowledge-pilot" && arg !== "--acknowledge-real-funds")), + /acknowledge-pilot/, + ); + + const missingConfirmation = await expectRejects( + "missing confirmation depth rejected", + () => parseBridgeArgs(common.filter((_, index, values) => values[index - 1] !== "--confirmations" && values[index] !== "--confirmations")), + /confirmations/, + ); + + const missingSupportedToken = await expectRejects( + "missing supported token rejected", + () => parseBridgeArgs(common.filter((_, index, values) => values[index - 1] !== "--supported-token" && values[index] !== "--supported-token")), + /supported-token/, + ); + + const broadScan = await expectRejects( + "broad block scan rejected", + () => parseBridgeArgs([...common.slice(0, common.indexOf("--to-block") + 1), "10000", ...common.slice(common.indexOf("--confirmations"))]), + /block range is too wide/, + ); + + const unapprovedLockbox = await expectRejects( + "unapproved lockbox rejected", + () => runBridgePipeline(parseBridgeArgs(common.map((arg, index, values) => values[index - 1] === "--approved-lockbox" ? "0x9999999999999999999999999999999999999999" : arg))), + /unapproved bridge lockbox/, + ); + + const originalFetch = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { method: string }; + calls.push(body.method); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x14a34" }), { + headers: { "content-type": "application/json" }, + }); + }; + let wrongChain = ""; + try { + wrongChain = await expectRejects( + "wrong chain rejected before log scan", + () => runBridgePipeline(parseBridgeArgs(common)), + /wrong chain id: expected 8453 \(0x2105\)/, + ); + assert.deepEqual(calls, ["eth_chainId"]); + } finally { + globalThis.fetch = originalFetch; + } + + return { + schema: "flowmemory.bridge_live_readiness_self_test.v0", + generatedAt: new Date().toISOString(), + status: "passed", + liveMode: false, + checks: [ + "missing env names are listed without printing values", + missingAck, + missingConfirmation, + missingSupportedToken, + broadScan, + unapprovedLockbox, + wrongChain, + ], + missingEnv, + simulatedMissingEnv, + requiredEnvNames: REQUIRED_ENV, + optionalLiveEnvNames: OPTIONAL_LIVE_ENV, + operatorAckRequiredValue: REQUIRED_ACK, + noSecrets: true, + }; +} + +async function liveCheck(): Promise> { + const missing = [ + ...REQUIRED_ENV, + "FLOWCHAIN_BASE8453_FROM_BLOCK", + "FLOWCHAIN_BASE8453_TO_BLOCK", + ].filter((name) => env(name) === undefined); + if (missing.length > 0) { + throw new Error(`Live readiness missing required env names: ${missing.join(", ")}`); + } + if (env("FLOWCHAIN_PILOT_OPERATOR_ACK") !== REQUIRED_ACK) { + throw new Error(`FLOWCHAIN_PILOT_OPERATOR_ACK must equal ${REQUIRED_ACK}`); + } + + const result = await runBridgePipeline(parseBridgeArgs(baseLiveArgs())); + return { + schema: "flowmemory.bridge_live_readiness_check.v0", + generatedAt: new Date().toISOString(), + status: "passed", + liveMode: true, + observedCount: result.observations.length, + creditStatuses: result.credits.map((credit) => credit.status), + requiredEnvNames: REQUIRED_ENV, + optionalLiveEnvNames: OPTIONAL_LIVE_ENV, + noSecrets: true, + }; +} + +const args = process.argv.slice(2); +const live = args.includes("--live"); +const outIndex = args.indexOf("--out"); +const outPath = outIndex >= 0 ? args[outIndex + 1] ?? DEFAULT_OUT : DEFAULT_OUT; + +const report = live ? await liveCheck() : await selfTest(); +writeJson(outPath, report); +console.log(live ? "Base 8453 live readiness check passed." : "Base 8453 live readiness self-test passed."); diff --git a/services/bridge-relayer/src/bridge-pilot-e2e.ts b/services/bridge-relayer/src/bridge-pilot-e2e.ts new file mode 100644 index 00000000..63ae82ef --- /dev/null +++ b/services/bridge-relayer/src/bridge-pilot-e2e.ts @@ -0,0 +1,581 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { assertNoSecrets } from "../../shared/src/index.ts"; +import { + BASE_MAINNET_CHAIN_ID, + BASE_MAINNET_CHAIN_ID_HEX, + parseBridgeArgs, + runBridgePipeline, + type BridgeCredit, + type BridgeDeposit, + type BridgeMode, + type BridgePipelineResult, + type BridgeWithdrawalIntent, +} from "./observe-base-lockbox.ts"; + +type PilotE2EMode = Extract; + +interface PilotE2EOptions { + mode: PilotE2EMode; + fixturePath: string; + duplicateFixturePath: string; + outDir: string; + approvedLockbox: string; + rpcEndpoint?: string; + lockboxAddress?: string; + fromBlock?: string; + toBlock?: string; + confirmations: string; + maxUsd: string; + maxDepositAmount: string; + totalCapAmount: string; + supportedToken: string; +} + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const DEFAULT_FIXTURE = resolve(REPO_ROOT, "fixtures/bridge/base8453-pilot-mock-deposit.json"); +const DEFAULT_DUPLICATE_FIXTURE = resolve(REPO_ROOT, "fixtures/bridge/base8453-pilot-duplicate-mock-deposits.json"); +const DEFAULT_OUT_DIR = resolve(REPO_ROOT, "services/bridge-relayer/out/real-value-pilot-e2e"); +const DEFAULT_APPROVED_LOCKBOX = "0x1111111111111111111111111111111111111111"; +const DEFAULT_SUPPORTED_TOKEN = "0x3333333333333333333333333333333333333333"; +const WRONG_APPROVED_LOCKBOX = "0x9999999999999999999999999999999999999999"; +const SECOND_FLOWCHAIN_RECIPIENT = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +interface BridgeLocalUsageProof { + schema: "flowmemory.bridge_local_usage_proof.v0"; + generatedAt: string; + creditId: `0x${string}`; + sourceRecipient: `0x${string}`; + secondRecipient: `0x${string}`; + transfer: { + transferId: `0x${string}`; + amount: string; + status: "prepared"; + broadcast: false; + }; + balances: { + creditedWalletAfterCredit: string; + creditedWalletAfterTransfer: string; + secondWalletAfterTransfer: string; + }; + productOrDexFlow: { + supportedByCommand: "npm run flowchain:product-e2e"; + bridgeRelayerExecutesProductTrade: false; + status: "covered_by_existing_local_gate"; + }; + localOnly: true; + noSecrets: true; +} + +interface BridgeWithdrawalAuthorization { + schema: "flowmemory.bridge_withdrawal_authorization.v0"; + generatedAt: string; + authorizationId: `0x${string}`; + withdrawalIntentId: `0x${string}`; + creditId: `0x${string}`; + depositId: `0x${string}`; + flowchainChainId: "flowchain-local-pilot-v0"; + destinationChainId: number; + token: `0x${string}`; + amount: string; + flowchainAccount: `0x${string}`; + baseRecipient: `0x${string}`; + withdrawalNonce: string; + signedBy: `0x${string}`; + signatureScheme: "flowchain-pilot-deterministic-test-signature-v0"; + signedPayloadHash: `0x${string}`; + signature: `0x${string}`; + localOnly: true; + noSecrets: true; +} + +function argValue(args: string[], index: number, name: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return value; +} + +function parsePilotE2EArgs(args: string[]): PilotE2EOptions { + let mode: PilotE2EMode = "mock-pilot"; + let fixturePath = DEFAULT_FIXTURE; + let duplicateFixturePath = DEFAULT_DUPLICATE_FIXTURE; + let outDir = DEFAULT_OUT_DIR; + let approvedLockbox = DEFAULT_APPROVED_LOCKBOX; + let rpcEndpoint: string | undefined; + let lockboxAddress: string | undefined; + let fromBlock: string | undefined; + let toBlock: string | undefined; + let confirmations = "2"; + let maxUsd = "1"; + let maxDepositAmount = "20000000"; + let totalCapAmount = "20000000"; + let supportedToken = DEFAULT_SUPPORTED_TOKEN; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--mode") { + const value = argValue(args, index, arg); + if (value !== "mock-pilot" && value !== "base-mainnet-pilot") { + throw new Error("--mode must be mock-pilot or base-mainnet-pilot"); + } + mode = value; + index += 1; + } else if (arg === "--fixture") { + fixturePath = resolve(argValue(args, index, arg)); + index += 1; + } else if (arg === "--duplicate-fixture") { + duplicateFixturePath = resolve(argValue(args, index, arg)); + index += 1; + } else if (arg === "--out-dir") { + outDir = resolve(argValue(args, index, arg)); + index += 1; + } else if (arg === "--approved-lockbox") { + approvedLockbox = argValue(args, index, arg); + index += 1; + } else if (arg === "--rpc-endpoint") { + rpcEndpoint = argValue(args, index, arg); + index += 1; + } else if (arg === "--lockbox-address") { + lockboxAddress = argValue(args, index, arg); + index += 1; + } else if (arg === "--from-block") { + fromBlock = argValue(args, index, arg); + index += 1; + } else if (arg === "--to-block") { + toBlock = argValue(args, index, arg); + index += 1; + } else if (arg === "--confirmations") { + confirmations = argValue(args, index, arg); + index += 1; + } else if (arg === "--max-usd") { + maxUsd = argValue(args, index, arg); + index += 1; + } else if (arg === "--max-deposit-amount") { + maxDepositAmount = argValue(args, index, arg); + index += 1; + } else if (arg === "--total-cap-amount") { + totalCapAmount = argValue(args, index, arg); + index += 1; + } else if (arg === "--supported-token") { + supportedToken = argValue(args, index, arg); + index += 1; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + + if (mode === "base-mainnet-pilot") { + const missing = [ + ["--rpc-endpoint", rpcEndpoint], + ["--lockbox-address", lockboxAddress], + ["--from-block", fromBlock], + ["--to-block", toBlock], + ].filter(([, value]) => value === undefined).map(([name]) => name); + if (missing.length > 0) { + throw new Error(`base-mainnet-pilot E2E requires ${missing.join(", ")}`); + } + } + + return { + mode, + fixturePath, + duplicateFixturePath, + outDir, + approvedLockbox, + rpcEndpoint, + lockboxAddress, + fromBlock, + toBlock, + confirmations, + maxUsd, + maxDepositAmount, + totalCapAmount, + supportedToken, + }; +} + +function pipelineArgs(options: PilotE2EOptions, statePath: string, fixturePath = options.fixturePath): string[] { + const common = [ + "--approved-lockbox", + options.approvedLockbox, + "--confirmations", + options.confirmations, + "--acknowledge-pilot", + "--max-usd", + options.maxUsd, + "--max-deposit-amount", + options.maxDepositAmount, + "--total-cap-amount", + options.totalCapAmount, + "--supported-token", + options.supportedToken, + "--apply-credit", + "--withdrawal-intent", + "--runtime-state", + statePath, + ]; + + if (options.mode === "mock-pilot") { + return [ + "--mode", + "mock-pilot", + "--fixture", + fixturePath, + ...common, + ]; + } + + return [ + "--mode", + "base-mainnet-pilot", + "--rpc-url", + options.rpcEndpoint ?? "", + "--lockbox-address", + options.lockboxAddress ?? "", + "--from-block", + options.fromBlock ?? "", + "--to-block", + options.toBlock ?? "", + "--acknowledge-real-funds", + ...common, + ]; +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + assertNoSecrets(value); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function relativeToRepo(path: string): string { + return relative(REPO_ROOT, path).replace(/\\/g, "/"); +} + +function first(values: T[], name: string): T { + const value = values[0]; + assert.ok(value !== undefined, `${name} must contain at least one entry`); + return value; +} + +function stableProofId(schema: string, value: unknown): `0x${string}` { + return `0x${createHash("sha256").update(JSON.stringify({ schema, value })).digest("hex")}`; +} + +function makeLocalUsageProof(credit: BridgeCredit): BridgeLocalUsageProof { + assert.equal(credit.status, "applied"); + const creditedAmount = BigInt(credit.amount); + assert.ok(creditedAmount > 0n, "local usage proof requires a positive credited amount"); + const transferAmount = creditedAmount / 2n > 0n ? creditedAmount / 2n : creditedAmount; + const creditedWalletAfterTransfer = creditedAmount - transferAmount; + const transferId = stableProofId("flowmemory.bridge_local_transfer.v0", { + creditId: credit.creditId, + from: credit.flowchainRecipient, + to: SECOND_FLOWCHAIN_RECIPIENT, + amount: transferAmount.toString(), + }); + + return { + schema: "flowmemory.bridge_local_usage_proof.v0", + generatedAt: new Date().toISOString(), + creditId: credit.creditId, + sourceRecipient: credit.flowchainRecipient, + secondRecipient: SECOND_FLOWCHAIN_RECIPIENT, + transfer: { + transferId, + amount: transferAmount.toString(), + status: "prepared", + broadcast: false, + }, + balances: { + creditedWalletAfterCredit: creditedAmount.toString(), + creditedWalletAfterTransfer: creditedWalletAfterTransfer.toString(), + secondWalletAfterTransfer: transferAmount.toString(), + }, + productOrDexFlow: { + supportedByCommand: "npm run flowchain:product-e2e", + bridgeRelayerExecutesProductTrade: false, + status: "covered_by_existing_local_gate", + }, + localOnly: true, + noSecrets: true, + }; +} + +function makeWithdrawalAuthorization( + withdrawal: BridgeWithdrawalIntent, + deposit: BridgeDeposit, +): BridgeWithdrawalAuthorization { + const flowchainChainId = "flowchain-local-pilot-v0"; + const withdrawalNonce = deposit.nonce; + const signedPayloadHash = stableProofId("flowmemory.bridge_withdrawal_payload.v0", { + withdrawalIntentId: withdrawal.withdrawalIntentId, + creditId: withdrawal.creditId, + depositId: withdrawal.depositId, + flowchainChainId, + destinationChainId: withdrawal.destinationChainId, + token: withdrawal.token, + amount: withdrawal.amount, + flowchainAccount: withdrawal.flowchainAccount, + baseRecipient: withdrawal.baseRecipient, + withdrawalNonce, + }); + const signature = stableProofId("flowmemory.bridge_withdrawal_signature.v0", { + signedBy: withdrawal.flowchainAccount, + signedPayloadHash, + signatureScheme: "flowchain-pilot-deterministic-test-signature-v0", + }); + + return { + schema: "flowmemory.bridge_withdrawal_authorization.v0", + generatedAt: new Date().toISOString(), + authorizationId: stableProofId("flowmemory.bridge_withdrawal_authorization.v0", { + withdrawalIntentId: withdrawal.withdrawalIntentId, + signedPayloadHash, + signature, + }), + withdrawalIntentId: withdrawal.withdrawalIntentId, + creditId: withdrawal.creditId, + depositId: withdrawal.depositId, + flowchainChainId, + destinationChainId: withdrawal.destinationChainId, + token: withdrawal.token, + amount: withdrawal.amount, + flowchainAccount: withdrawal.flowchainAccount, + baseRecipient: withdrawal.baseRecipient, + withdrawalNonce, + signedBy: withdrawal.flowchainAccount, + signatureScheme: "flowchain-pilot-deterministic-test-signature-v0", + signedPayloadHash, + signature, + localOnly: true, + noSecrets: true, + }; +} + +async function runFirstAndReplay(options: PilotE2EOptions, statePath: string): Promise<{ + firstRun: BridgePipelineResult; + replayRun: BridgePipelineResult; +}> { + const firstRun = await runBridgePipeline(parseBridgeArgs(pipelineArgs(options, statePath))); + const firstCredit = first(firstRun.credits, "first credits"); + const firstApplication = first(firstRun.runtimeApplications, "first runtime applications"); + const firstWithdrawal = first(firstRun.withdrawalIntents, "first withdrawal intents"); + const firstEvidence = first(firstRun.pilotEvidence, "first pilot evidence"); + const firstReleaseEvidence = first(firstRun.releaseEvidences, "first release evidence"); + + assert.equal(firstCredit.status, "applied"); + assert.equal(firstApplication.status, "applied"); + assert.equal(firstApplication.applyCount, 1); + assert.equal(firstEvidence.creditApplication.appliedExactlyOnce, true); + assert.equal(firstWithdrawal.broadcast, false); + assert.equal(firstReleaseEvidence.releaseCall.broadcast, false); + + const replayRun = await runBridgePipeline(parseBridgeArgs(pipelineArgs(options, statePath))); + const replayCredit = first(replayRun.credits, "replay credits"); + const replayApplication = first(replayRun.runtimeApplications, "replay runtime applications"); + const replayEvidence = first(replayRun.pilotEvidence, "replay pilot evidence"); + + assert.equal(replayCredit.status, "rejected"); + assert.equal(replayCredit.rejectionReason, "already_applied_replay_key"); + assert.equal(replayApplication.status, "idempotent_replay"); + assert.equal(replayApplication.applyCount, 0); + assert.equal(replayEvidence.replay.decision, "already_applied_idempotent"); + assert.equal(replayRun.withdrawalIntents.length, 0); + + return { firstRun, replayRun }; +} + +async function runDuplicateReplay(options: PilotE2EOptions, statePath: string): Promise { + const duplicateRun = await runBridgePipeline(parseBridgeArgs(pipelineArgs(options, statePath, options.duplicateFixturePath))); + const applied = duplicateRun.credits.filter((credit) => credit.status === "applied"); + const rejected = duplicateRun.credits.filter((credit) => credit.status === "rejected"); + + assert.equal(duplicateRun.observations.length, 2); + assert.equal(applied.length, 1); + assert.equal(rejected.length, 1); + assert.equal(rejected[0]?.rejectionReason, "duplicate_replay_key"); + assert.equal(duplicateRun.handoff.replayProtection.duplicateReplayKeys.length, 1); + assert.equal(duplicateRun.withdrawalIntents.length, 1); + return duplicateRun; +} + +async function assertNegativeCoverage(options: PilotE2EOptions): Promise<{ + wrongChainRejected: boolean; + unapprovedContractRejected: boolean; +}> { + await assert.rejects( + () => runBridgePipeline(parseBridgeArgs(pipelineArgs({ + ...options, + fixturePath: resolve(REPO_ROOT, "fixtures/bridge/base-sepolia-mock-deposit.json"), + }, resolve(options.outDir, "wrong-chain-state.json")))), + /pilot deposit must be from Base chain 8453/, + ); + + await assert.rejects( + () => runBridgePipeline(parseBridgeArgs(pipelineArgs({ + ...options, + approvedLockbox: WRONG_APPROVED_LOCKBOX, + }, resolve(options.outDir, "unapproved-state.json")))), + /unapproved bridge lockbox address/, + ); + + return { + wrongChainRejected: true, + unapprovedContractRejected: true, + }; +} + +async function main(): Promise { + const options = parsePilotE2EArgs(process.argv.slice(2)); + rmSync(options.outDir, { recursive: true, force: true }); + mkdirSync(options.outDir, { recursive: true }); + + console.log(`Step 1 complete: resolved bridge pilot E2E mode ${options.mode}.`); + console.log("Next operator command: node services/bridge-relayer/src/bridge-pilot-e2e.ts --mode mock-pilot"); + + const statePath = resolve(options.outDir, "bridge-credit-application-state.json"); + const duplicateStatePath = resolve(options.outDir, "bridge-duplicate-credit-application-state.json"); + const { firstRun, replayRun } = await runFirstAndReplay(options, statePath); + console.log("Step 2 complete: first local credit application and same-event replay were checked."); + console.log("Next operator command: Get-Content services/bridge-relayer/out/real-value-pilot-e2e/bridge-pilot-evidence.json"); + + const duplicateRun = await runDuplicateReplay(options, duplicateStatePath); + console.log("Step 3 complete: duplicate deposit fixture replay was rejected with evidence."); + console.log("Next operator command: Get-Content services/bridge-relayer/out/real-value-pilot-e2e/bridge-replay-handoff.json"); + + const negativeCoverage = await assertNegativeCoverage(options); + console.log("Step 4 complete: wrong-chain and unapproved-contract negative checks passed."); + console.log("Next operator command: npm test --prefix services/bridge-relayer"); + + const observation = first(firstRun.observations, "observations"); + const credit = first(firstRun.credits, "credits"); + const evidence = first(firstRun.pilotEvidence, "pilot evidence"); + const withdrawal = first(firstRun.withdrawalIntents, "withdrawal intents"); + const releaseEvidence = first(firstRun.releaseEvidences, "release evidence"); + const localUsage = makeLocalUsageProof(credit); + const withdrawalAuthorization = makeWithdrawalAuthorization(withdrawal, observation.deposit); + + const observationPath = resolve(options.outDir, "bridge-observation.json"); + const creditPath = resolve(options.outDir, "bridge-credit.json"); + const evidencePath = resolve(options.outDir, "bridge-pilot-evidence.json"); + const releaseEvidencePath = resolve(options.outDir, "bridge-release-evidence.json"); + const withdrawalPath = resolve(options.outDir, "bridge-withdrawal-intent.json"); + const withdrawalAuthorizationPath = resolve(options.outDir, "bridge-withdrawal-authorization.json"); + const localUsagePath = resolve(options.outDir, "bridge-local-usage-proof.json"); + const handoffPath = resolve(options.outDir, "bridge-runtime-handoff.json"); + const replayHandoffPath = resolve(options.outDir, "bridge-replay-handoff.json"); + + writeJson(observationPath, observation); + writeJson(creditPath, credit); + writeJson(evidencePath, evidence); + writeJson(releaseEvidencePath, releaseEvidence); + writeJson(withdrawalPath, withdrawal); + writeJson(withdrawalAuthorizationPath, withdrawalAuthorization); + writeJson(localUsagePath, localUsage); + writeJson(handoffPath, firstRun.handoff); + writeJson(replayHandoffPath, duplicateRun.handoff); + + [ + observation, + credit, + evidence, + releaseEvidence, + withdrawal, + withdrawalAuthorization, + localUsage, + firstRun.handoff, + replayRun.handoff, + duplicateRun.handoff, + ].forEach((artifact) => assertNoSecrets(artifact)); + + const report = { + schema: "flowmemory.bridge_real_value_pilot_e2e_report.v0", + generatedAt: new Date().toISOString(), + mode: options.mode, + sourceChain: { + chainId: BASE_MAINNET_CHAIN_ID, + chainIdHex: BASE_MAINNET_CHAIN_ID_HEX, + }, + productionReady: false, + broadcast: false, + artifacts: { + observationPath: relativeToRepo(observationPath), + creditPath: relativeToRepo(creditPath), + pilotEvidencePath: relativeToRepo(evidencePath), + releaseEvidencePath: relativeToRepo(releaseEvidencePath), + withdrawalIntentPath: relativeToRepo(withdrawalPath), + withdrawalAuthorizationPath: relativeToRepo(withdrawalAuthorizationPath), + localUsagePath: relativeToRepo(localUsagePath), + runtimeHandoffPath: relativeToRepo(handoffPath), + replayHandoffPath: relativeToRepo(replayHandoffPath), + applicationStatePath: relativeToRepo(statePath), + }, + deterministicIds: { + observationId: observation.observationId, + replayKey: observation.replayKey, + creditId: credit.creditId, + evidenceId: evidence.evidenceId, + releaseEvidenceId: releaseEvidence.releaseEvidenceId, + withdrawalIntentId: withdrawal.withdrawalIntentId, + withdrawalAuthorizationId: withdrawalAuthorization.authorizationId, + localTransferId: localUsage.transfer.transferId, + }, + supportedToken: options.supportedToken, + localUsage: { + transferPrepared: true, + secondRecipient: localUsage.secondRecipient, + transferAmount: localUsage.transfer.amount, + productOrDexCoveredBy: localUsage.productOrDexFlow.supportedByCommand, + }, + exactlyOnce: { + firstApplicationStatus: first(firstRun.runtimeApplications, "first applications").status, + replayApplicationStatus: first(replayRun.runtimeApplications, "replay applications").status, + replayCreditStatus: first(replayRun.credits, "replay credits").status, + appliedOnce: true, + }, + replayProtection: { + duplicateReplayKeys: duplicateRun.handoff.replayProtection.duplicateReplayKeys, + rejectedCreditIds: duplicateRun.credits + .filter((replayCredit) => replayCredit.status === "rejected") + .map((replayCredit) => replayCredit.creditId), + decision: "duplicate_replay_key_rejected", + }, + withdrawalReleaseEvidence: { + withdrawalIntentId: withdrawal.withdrawalIntentId, + releaseEvidenceId: releaseEvidence.releaseEvidenceId, + broadcast: releaseEvidence.releaseCall.broadcast, + method: releaseEvidence.releaseCall.method, + }, + negativeCoverage, + requiredEnvironmentVariables: [ + "FLOWCHAIN_BASE8453_RPC_URL", + "FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS", + "FLOWCHAIN_BASE8453_SUPPORTED_TOKEN", + "FLOWCHAIN_BASE8453_FROM_BLOCK", + "FLOWCHAIN_BASE8453_TO_BLOCK", + "FLOWCHAIN_PILOT_CONFIRMATIONS", + "FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI", + "FLOWCHAIN_PILOT_TOTAL_CAP_WEI", + "FLOWCHAIN_PILOT_MAX_USD", + "FLOWCHAIN_PILOT_OPERATOR_ACK", + ], + liveObserverCommand: "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-mainnet-pilot-observe.ps1 -OperatorAck -ApplyCredit -WithdrawalIntent", + noSecrets: true, + }; + assertNoSecrets(report); + + const reportPath = resolve(options.outDir, "bridge-real-value-pilot-e2e-report.json"); + writeJson(reportPath, report); + + console.log("Step 5 complete: bridge pilot E2E artifacts were written."); + console.log(`Next operator command: Get-Content ${relativeToRepo(reportPath)}`); + console.log(`Bridge pilot E2E report: ${relativeToRepo(reportPath)}`); +} + +await main(); diff --git a/services/bridge-relayer/src/observe-base-lockbox.ts b/services/bridge-relayer/src/observe-base-lockbox.ts index c4234953..f355a6e0 100644 --- a/services/bridge-relayer/src/observe-base-lockbox.ts +++ b/services/bridge-relayer/src/observe-base-lockbox.ts @@ -1,9 +1,10 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { canonicalJson, + assertNoSecrets, decodeAddressTopic, decodeBytes32Word, decodeUint256Word, @@ -14,13 +15,21 @@ import { } from "../../shared/src/index.ts"; export const BASE_MAINNET_CHAIN_ID = 8453; +export const BASE_MAINNET_CHAIN_ID_HEX = "0x2105"; export const BASE_SEPOLIA_CHAIN_ID = 84532; export const LOCAL_ANVIL_CHAIN_ID = 31337; export const MAX_CANARY_USD = 25; +export const MAX_PILOT_USD = 25; export const MAX_BLOCK_RANGE = 5_000n; +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; +export const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000"; export const BRIDGE_DEPOSIT_EVENT_SIGNATURE_TEXT = + "BridgeDeposit(bytes32,uint256,address,address,address,uint256,bytes32,uint256,bytes32,bytes32)"; +export const BRIDGE_DEPOSIT_LEGACY_EVENT_SIGNATURE_TEXT = "BridgeDeposit(bytes32,uint256,address,address,uint256,bytes32,uint256,bytes32)"; export const BRIDGE_DEPOSIT_TOPIC0 = keccak256Utf8(BRIDGE_DEPOSIT_EVENT_SIGNATURE_TEXT); +export const BRIDGE_DEPOSIT_LEGACY_TOPIC0 = keccak256Utf8(BRIDGE_DEPOSIT_LEGACY_EVENT_SIGNATURE_TEXT); +export const PILOT_MODE_TAG = keccak256Utf8("flowchain.base8453.owner-pilot.v0"); export const FIXED_TEST_OBSERVED_AT = "2026-05-13T00:00:00.000Z"; type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue | undefined }; @@ -30,7 +39,21 @@ export type BridgeSourceChainId = | typeof BASE_SEPOLIA_CHAIN_ID | typeof BASE_MAINNET_CHAIN_ID; -export type BridgeMode = "mock" | "local-anvil" | "base-sepolia" | "base-mainnet-canary"; +export type BridgeMode = + | "mock" + | "mock-pilot" + | "local-anvil" + | "base-sepolia" + | "base-mainnet-canary" + | "base-mainnet-pilot"; + +export interface BridgeConfirmationEvidence { + depth: number; + latestBlockNumber?: string; + requiredConfirmedBlockNumber?: string; + requestedToBlock?: string; + satisfied: boolean; +} export interface BridgeDeposit { schema: "flowmemory.bridge_deposit.v0"; @@ -65,6 +88,12 @@ export interface BridgeObservation { explicitBlockRange: boolean; noSecrets: boolean; maxUsd?: number; + maxDepositAmount?: string; + totalCapAmount?: string; + pilotModeTag: `0x${string}`; + supportedTokens?: `0x${string}`[]; + confirmation?: BridgeConfirmationEvidence; + approvedContract?: boolean; }; } @@ -138,6 +167,88 @@ export interface BridgeWithdrawalIntentSet { productionReady: false; } +export interface BridgeRuntimeCreditApplication { + schema: "flowmemory.bridge_runtime_credit_application.v0"; + applicationId: `0x${string}`; + creditId: `0x${string}`; + depositId: `0x${string}`; + replayKey: `0x${string}`; + flowchainRecipient: `0x${string}`; + amount: string; + status: "applied" | "idempotent_replay" | "rejected"; + appliedAt?: string; + previousApplicationId?: `0x${string}`; + rejectionReason?: string; + applyCount: 0 | 1; + localOnly: true; + productionReady: false; +} + +export interface BridgePilotEvidence { + schema: "flowmemory.bridge_pilot_evidence.v0"; + evidenceId: `0x${string}`; + generatedAt: string; + mode: BridgeMode; + productionReady: false; + localOnly: true; + observationId: `0x${string}`; + creditId: `0x${string}`; + depositId: `0x${string}`; + replayKey: `0x${string}`; + source: { + chainId: BridgeSourceChainId; + chainIdHex: `0x${string}`; + contract: `0x${string}`; + txHash: `0x${string}`; + logIndex: number; + blockNumber?: string; + }; + guardrails: { + approvedContract: boolean; + confirmation: BridgeConfirmationEvidence; + maxUsd?: number; + maxDepositAmount?: string; + totalCapAmount?: string; + supportedTokens?: `0x${string}`[]; + operatorAcknowledged: boolean; + noSecrets: true; + }; + creditApplication: { + applicationId?: `0x${string}`; + status: BridgeRuntimeCreditApplication["status"] | BridgeCredit["status"]; + appliedExactlyOnce: boolean; + rejectionReason?: string; + }; + replay: { + decision: "accepted_once" | "duplicate_replay_key_rejected" | "already_applied_idempotent"; + duplicateReplayKeys: `0x${string}`[]; + }; + nextOperatorCommands: string[]; +} + +export interface BridgeReleaseEvidence { + schema: "flowmemory.bridge_release_evidence.v0"; + releaseEvidenceId: `0x${string}`; + generatedAt: string; + withdrawalIntentId: `0x${string}`; + creditId: `0x${string}`; + depositId: `0x${string}`; + sourceChainId: BridgeSourceChainId; + destinationChainId: BridgeSourceChainId; + lockbox: `0x${string}`; + releaseCall: { + method: "releaseERC20" | "releaseNative"; + recipient: `0x${string}`; + token: `0x${string}`; + amount: string; + evidenceHash: `0x${string}`; + broadcast: false; + }; + operatorNote: string; + productionReady: false; + localOnly: true; +} + export interface BridgeRuntimeHandoff { schema: "flowmemory.bridge_runtime_handoff.v0"; handoffId: `0x${string}`; @@ -148,6 +259,9 @@ export interface BridgeRuntimeHandoff { observations: BridgeObservation[]; credits: BridgeCredit[]; withdrawalIntents: BridgeWithdrawalIntent[]; + runtimeApplications: BridgeRuntimeCreditApplication[]; + pilotEvidence: BridgePilotEvidence[]; + releaseEvidences: BridgeReleaseEvidence[]; replayProtection: { strategy: "source-chain-contract-tx-log-deposit"; replayKeys: `0x${string}`[]; @@ -191,17 +305,29 @@ interface CliOptions { fromBlock?: string; toBlock?: string; expectedChainId?: BridgeSourceChainId; + approvedLockboxAddresses: `0x${string}`[]; + supportedTokens: `0x${string}`[]; + confirmationDepth: number; acknowledgeRealFunds: boolean; + acknowledgePilot: boolean; maxUsd?: number; + maxDepositAmount?: string; + totalCapAmount?: string; applyCredit: boolean; withdrawalIntent: boolean; withdrawalBaseRecipient?: `0x${string}`; + runtimeStatePath?: string; + evidenceOutPath?: string; + releaseEvidenceOutPath?: string; } export interface BridgePipelineResult { observations: BridgeObservation[]; credits: BridgeCredit[]; withdrawalIntents: BridgeWithdrawalIntent[]; + runtimeApplications: BridgeRuntimeCreditApplication[]; + pilotEvidence: BridgePilotEvidence[]; + releaseEvidences: BridgeReleaseEvidence[]; handoff: BridgeRuntimeHandoff; } @@ -253,6 +379,14 @@ function asDecimalString(value: unknown, name: string): string { return text; } +function asPositiveDecimalString(value: unknown, name: string): string { + const text = asDecimalString(value, name); + if (BigInt(text) <= 0n) { + throw new Error(`${name} must be greater than zero`); + } + return text; +} + function asNonNegativeInteger(value: unknown, name: string): number { const number = Number(value); if (!Number.isInteger(number) || number < 0) { @@ -280,6 +414,34 @@ function asSourceChainId(value: unknown, name: string): BridgeSourceChainId { return chainId as BridgeSourceChainId; } +function isMockMode(mode: BridgeMode): boolean { + return mode === "mock" || mode === "mock-pilot"; +} + +function isPilotMode(mode: BridgeMode): boolean { + return mode === "mock-pilot" || mode === "base-mainnet-pilot"; +} + +function chainIdHex(chainId: BridgeSourceChainId): `0x${string}` { + return `0x${chainId.toString(16)}`; +} + +function parseApprovedLockboxes(value: string, name: string): `0x${string}`[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => asAddress(entry, name)); +} + +function parseAddressList(value: string, name: string): `0x${string}`[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => asAddress(entry, name)); +} + function expectedChainIdForMode(mode: BridgeMode, explicit?: BridgeSourceChainId): BridgeSourceChainId { if (explicit !== undefined) { return explicit; @@ -290,6 +452,9 @@ function expectedChainIdForMode(mode: BridgeMode, explicit?: BridgeSourceChainId if (mode === "base-sepolia") { return BASE_SEPOLIA_CHAIN_ID; } + if (mode === "mock") { + return BASE_SEPOLIA_CHAIN_ID; + } return BASE_MAINNET_CHAIN_ID; } @@ -305,11 +470,20 @@ export function parseBridgeArgs(args: string[]): CliOptions { let fromBlock: string | undefined; let toBlock: string | undefined; let expectedChainId: BridgeSourceChainId | undefined; + let approvedLockboxAddresses: `0x${string}`[] = []; + let supportedTokens: `0x${string}`[] = []; + let confirmationDepth: number | undefined; let acknowledgeRealFunds = false; + let acknowledgePilot = false; let maxUsd: number | undefined; + let maxDepositAmount: string | undefined; + let totalCapAmount: string | undefined; let applyCredit = false; let withdrawalIntent = false; let withdrawalBaseRecipient: `0x${string}` | undefined; + let runtimeStatePath: string | undefined; + let evidenceOutPath: string | undefined; + let releaseEvidenceOutPath: string | undefined; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -317,11 +491,13 @@ export function parseBridgeArgs(args: string[]): CliOptions { const value = argValue(args, index, arg); if ( value !== "mock" + && value !== "mock-pilot" && value !== "local-anvil" && value !== "base-sepolia" && value !== "base-mainnet-canary" + && value !== "base-mainnet-pilot" ) { - throw new Error("--mode must be mock, local-anvil, base-sepolia, or base-mainnet-canary"); + throw new Error("--mode must be mock, mock-pilot, local-anvil, base-sepolia, base-mainnet-canary, or base-mainnet-pilot"); } mode = value; index += 1; @@ -355,11 +531,40 @@ export function parseBridgeArgs(args: string[]): CliOptions { } else if (arg === "--expected-chain-id") { expectedChainId = asSourceChainId(argValue(args, index, arg), arg); index += 1; + } else if (arg === "--approved-lockbox" || arg === "--approved-lockbox-address") { + approvedLockboxAddresses.push(asAddress(argValue(args, index, arg), arg)); + index += 1; + } else if (arg === "--approved-lockboxes" || arg === "--approved-lockbox-addresses") { + approvedLockboxAddresses = [ + ...approvedLockboxAddresses, + ...parseApprovedLockboxes(argValue(args, index, arg), arg), + ]; + index += 1; + } else if (arg === "--supported-token") { + supportedTokens.push(asAddress(argValue(args, index, arg), arg)); + index += 1; + } else if (arg === "--supported-tokens") { + supportedTokens = [ + ...supportedTokens, + ...parseAddressList(argValue(args, index, arg), arg), + ]; + index += 1; + } else if (arg === "--confirmations" || arg === "--confirmation-depth") { + confirmationDepth = asNonNegativeInteger(argValue(args, index, arg), arg); + index += 1; } else if (arg === "--acknowledge-real-funds") { acknowledgeRealFunds = true; + } else if (arg === "--acknowledge-pilot") { + acknowledgePilot = true; } else if (arg === "--max-usd") { maxUsd = Number(argValue(args, index, arg)); index += 1; + } else if (arg === "--max-deposit-amount") { + maxDepositAmount = asPositiveDecimalString(argValue(args, index, arg), arg); + index += 1; + } else if (arg === "--total-cap-amount") { + totalCapAmount = asPositiveDecimalString(argValue(args, index, arg), arg); + index += 1; } else if (arg === "--apply-credit") { applyCredit = true; } else if (arg === "--withdrawal-intent") { @@ -367,16 +572,25 @@ export function parseBridgeArgs(args: string[]): CliOptions { } else if (arg === "--withdrawal-base-recipient") { withdrawalBaseRecipient = asAddress(argValue(args, index, arg), arg); index += 1; + } else if (arg === "--runtime-state") { + runtimeStatePath = argValue(args, index, arg); + index += 1; + } else if (arg === "--evidence-out") { + evidenceOutPath = argValue(args, index, arg); + index += 1; + } else if (arg === "--release-evidence-out") { + releaseEvidenceOutPath = argValue(args, index, arg); + index += 1; } else { throw new Error(`unknown argument: ${arg}`); } } - if (mode === "mock" && !fixturePath) { - throw new Error("--fixture is required in mock mode"); + if (isMockMode(mode) && !fixturePath) { + throw new Error("--fixture is required in mock modes"); } - if (mode !== "mock") { + if (!isMockMode(mode)) { if (!rpcUrl || !lockboxAddress || !fromBlock || !toBlock) { throw new Error("--rpc-url, --lockbox-address, --from-block, and --to-block are required for RPC reads"); } @@ -397,8 +611,46 @@ export function parseBridgeArgs(args: string[]): CliOptions { if (maxUsd === undefined || !Number.isFinite(maxUsd) || maxUsd <= 0 || maxUsd > MAX_CANARY_USD) { throw new Error(`Base mainnet canary requires --max-usd <= ${MAX_CANARY_USD}`); } + if (applyCredit) { + throw new Error("Base mainnet canary is read-only; --apply-credit requires the explicit pilot mode"); + } + if (withdrawalIntent) { + throw new Error("Base mainnet canary is read-only; withdrawal intents require the explicit pilot mode"); + } + } + + if (isPilotMode(mode)) { + if (!acknowledgePilot) { + throw new Error("Base pilot modes require --acknowledge-pilot"); + } + if (mode === "base-mainnet-pilot" && !acknowledgeRealFunds) { + throw new Error("Base mainnet pilot requires --acknowledge-real-funds"); + } + if (maxUsd === undefined || !Number.isFinite(maxUsd) || maxUsd <= 0 || maxUsd > MAX_PILOT_USD) { + throw new Error(`Base pilot modes require --max-usd <= ${MAX_PILOT_USD}`); + } + if (maxDepositAmount === undefined) { + throw new Error("Base pilot modes require --max-deposit-amount"); + } + if (totalCapAmount === undefined) { + throw new Error("Base pilot modes require --total-cap-amount"); + } + if (BigInt(totalCapAmount) < BigInt(maxDepositAmount)) { + throw new Error("--total-cap-amount must be greater than or equal to --max-deposit-amount"); + } + if (approvedLockboxAddresses.length === 0) { + throw new Error("Base pilot modes require at least one --approved-lockbox"); + } + if (supportedTokens.length === 0) { + throw new Error("Base pilot modes require at least one --supported-token"); + } + if (confirmationDepth === undefined) { + throw new Error("Base pilot modes require --confirmations"); + } } + const resolvedConfirmationDepth = confirmationDepth ?? 0; + return { mode, fixturePath, @@ -411,11 +663,20 @@ export function parseBridgeArgs(args: string[]): CliOptions { fromBlock, toBlock, expectedChainId, + approvedLockboxAddresses: [...new Set(approvedLockboxAddresses)].sort() as `0x${string}`[], + supportedTokens: [...new Set(supportedTokens)].sort() as `0x${string}`[], + confirmationDepth: resolvedConfirmationDepth, acknowledgeRealFunds, + acknowledgePilot, maxUsd, + maxDepositAmount, + totalCapAmount, applyCredit, withdrawalIntent, withdrawalBaseRecipient, + runtimeStatePath, + evidenceOutPath, + releaseEvidenceOutPath, }; } @@ -458,7 +719,7 @@ export function validateDeposit(value: unknown): BridgeDeposit { }; } -function fixtureDeposits(fixture: unknown): BridgeDeposit[] { +export function fixtureDeposits(fixture: unknown): BridgeDeposit[] { if (fixture !== null && typeof fixture === "object" && !Array.isArray(fixture)) { const maybeBatch = fixture as Record; if (Array.isArray(maybeBatch.deposits)) { @@ -478,11 +739,28 @@ export function bridgeReplayKey(deposit: BridgeDeposit): `0x${string}` { }); } +interface BridgeGuardrailOptions { + maxUsd?: number; + maxDepositAmount?: string; + totalCapAmount?: string; + supportedTokens?: `0x${string}`[]; + confirmation?: BridgeConfirmationEvidence; + approvedContract?: boolean; +} + +function normalizeGuardrailOptions(value?: number | BridgeGuardrailOptions): BridgeGuardrailOptions { + if (typeof value === "number") { + return { maxUsd: value }; + } + return value ?? {}; +} + export function makeObservation( deposit: BridgeDeposit, mode: BridgeObservation["mode"], - maxUsd?: number, + guardrailOptions?: number | BridgeGuardrailOptions, ): BridgeObservation { + const guardrails = normalizeGuardrailOptions(guardrailOptions); const replayKey = bridgeReplayKey(deposit); return { schema: "flowmemory.bridge_deposit_observation.v0", @@ -499,9 +777,14 @@ export function makeObservation( guardrails: { explicitChainId: true, explicitContract: true, - explicitBlockRange: mode !== "mock", + explicitBlockRange: !isMockMode(mode), noSecrets: true, - ...(maxUsd === undefined ? {} : { maxUsd }), + ...(guardrails.maxUsd === undefined ? {} : { maxUsd: guardrails.maxUsd }), + ...(guardrails.maxDepositAmount === undefined ? {} : { maxDepositAmount: guardrails.maxDepositAmount }), + ...(guardrails.totalCapAmount === undefined ? {} : { totalCapAmount: guardrails.totalCapAmount }), + ...(guardrails.supportedTokens === undefined ? {} : { supportedTokens: guardrails.supportedTokens }), + ...(guardrails.confirmation === undefined ? {} : { confirmation: guardrails.confirmation }), + ...(guardrails.approvedContract === undefined ? {} : { approvedContract: guardrails.approvedContract }), }, }; } @@ -579,6 +862,9 @@ function makeCredits(observations: BridgeObservation[], applyCredit: boolean): B return makeBridgeCredit(observation, "rejected", "duplicate_replay_key"); } seen.add(observation.replayKey); + if (observation.mode === "base-mainnet-canary") { + return makeBridgeCredit(observation, "rejected", "base_mainnet_canary_read_only"); + } return makeBridgeCredit(observation, applyCredit ? "applied" : "pending"); }); } @@ -642,11 +928,145 @@ function duplicateReplayKeys(observations: BridgeObservation[]): `0x${string}`[] return [...duplicates].sort(); } +interface BridgeRuntimeCreditApplicationState { + schema: "flowmemory.bridge_runtime_credit_application_state.v0"; + stateId: `0x${string}`; + updatedAt: string; + appliedReplayKeys: Record; + applications: BridgeRuntimeCreditApplication[]; + localOnly: true; + productionReady: false; +} + +function emptyApplicationState(): BridgeRuntimeCreditApplicationState { + return { + schema: "flowmemory.bridge_runtime_credit_application_state.v0", + stateId: stableId("flowmemory.bridge_runtime_credit_application_state.v0", { applications: [] }), + updatedAt: FIXED_TEST_OBSERVED_AT, + appliedReplayKeys: {}, + applications: [], + localOnly: true, + productionReady: false, + }; +} + +function loadApplicationState(path?: string): BridgeRuntimeCreditApplicationState { + if (path === undefined || !existsSync(resolve(path))) { + return emptyApplicationState(); + } + const parsed = JSON.parse(readFileSync(resolve(path), "utf8")) as Partial; + if (parsed.schema !== "flowmemory.bridge_runtime_credit_application_state.v0") { + throw new Error("unsupported bridge runtime credit application state schema"); + } + return { + schema: "flowmemory.bridge_runtime_credit_application_state.v0", + stateId: asHash(String(parsed.stateId), "stateId"), + updatedAt: String(parsed.updatedAt ?? FIXED_TEST_OBSERVED_AT), + appliedReplayKeys: Object.fromEntries( + Object.entries(parsed.appliedReplayKeys ?? {}).map(([key, value]) => [ + asHash(key, "appliedReplayKeys key"), + asHash(String(value), "appliedReplayKeys value"), + ]), + ), + applications: Array.isArray(parsed.applications) ? parsed.applications as BridgeRuntimeCreditApplication[] : [], + localOnly: true, + productionReady: false, + }; +} + +function makeRuntimeApplication( + credit: BridgeCredit, + status: BridgeRuntimeCreditApplication["status"], + previousApplicationId?: `0x${string}`, + rejectionReason?: string, +): BridgeRuntimeCreditApplication { + const applyCount = status === "applied" ? 1 : 0; + return { + schema: "flowmemory.bridge_runtime_credit_application.v0", + applicationId: stableId("flowmemory.bridge_runtime_credit_application.v0", { + creditId: credit.creditId, + replayKey: credit.replayKey, + status, + previousApplicationId: previousApplicationId ?? null, + }), + creditId: credit.creditId, + depositId: credit.depositId, + replayKey: credit.replayKey, + flowchainRecipient: credit.flowchainRecipient, + amount: credit.amount, + status, + appliedAt: status === "applied" ? FIXED_TEST_OBSERVED_AT : undefined, + previousApplicationId, + rejectionReason, + applyCount, + localOnly: true, + productionReady: false, + }; +} + +function saveApplicationState(path: string, state: BridgeRuntimeCreditApplicationState): void { + const stateId = stableId("flowmemory.bridge_runtime_credit_application_state.v0", { + applications: state.applications.map((application) => application.applicationId), + appliedReplayKeys: state.appliedReplayKeys, + }); + const normalized: BridgeRuntimeCreditApplicationState = { + ...state, + stateId, + updatedAt: FIXED_TEST_OBSERVED_AT, + }; + const outPath = resolve(path); + mkdirSync(dirname(outPath), { recursive: true }); + assertNoSecrets(normalized); + writeFileSync(outPath, `${JSON.stringify(normalized, null, 2)}\n`); +} + +function applyCreditsExactlyOnce( + credits: BridgeCredit[], + runtimeStatePath?: string, +): BridgeRuntimeCreditApplication[] { + const state = loadApplicationState(runtimeStatePath); + const applications: BridgeRuntimeCreditApplication[] = []; + let changed = false; + + for (const credit of credits) { + if (credit.status !== "applied") { + if (credit.status === "rejected") { + applications.push(makeRuntimeApplication(credit, "rejected", undefined, credit.rejectionReason)); + } + continue; + } + + const previousApplicationId = state.appliedReplayKeys[credit.replayKey]; + if (previousApplicationId !== undefined) { + credit.status = "rejected"; + credit.appliedAt = undefined; + credit.rejectionReason = "already_applied_replay_key"; + applications.push(makeRuntimeApplication(credit, "idempotent_replay", previousApplicationId, credit.rejectionReason)); + continue; + } + + const application = makeRuntimeApplication(credit, "applied"); + state.appliedReplayKeys[credit.replayKey] = application.applicationId; + state.applications.push(application); + applications.push(application); + changed = true; + } + + if (runtimeStatePath !== undefined && changed) { + saveApplicationState(runtimeStatePath, state); + } + + return applications; +} + export function makeRuntimeHandoff( mode: BridgeMode, observations: BridgeObservation[], credits: BridgeCredit[], withdrawalIntents: BridgeWithdrawalIntent[], + runtimeApplications: BridgeRuntimeCreditApplication[] = [], + pilotEvidence: BridgePilotEvidence[] = [], + releaseEvidences: BridgeReleaseEvidence[] = [], expectedPath = "fixtures/bridge/local-runtime-bridge-handoff.json", ): BridgeRuntimeHandoff { const normalizedExpectedPath = normalizeHandoffExpectedPath(expectedPath); @@ -749,6 +1169,9 @@ export function makeRuntimeHandoff( observationIds: observations.map((observation) => observation.observationId), creditIds: credits.map((credit) => credit.creditId), withdrawalIntentIds: withdrawalIntents.map((intent) => intent.withdrawalIntentId), + runtimeApplicationIds: runtimeApplications.map((application) => application.applicationId), + pilotEvidenceIds: pilotEvidence.map((evidence) => evidence.evidenceId), + releaseEvidenceIds: releaseEvidences.map((evidence) => evidence.releaseEvidenceId), }), generatedAt: FIXED_TEST_OBSERVED_AT, mode, @@ -757,6 +1180,9 @@ export function makeRuntimeHandoff( observations, credits, withdrawalIntents, + runtimeApplications, + pilotEvidence, + releaseEvidences, replayProtection: { strategy: "source-chain-contract-tx-log-deposit", replayKeys, @@ -811,20 +1237,203 @@ function hexQuantityToNumber(value: string | undefined, name: string): number | return parsed; } +function confirmationForEvidence(options: CliOptions, confirmation?: BridgeConfirmationEvidence): BridgeConfirmationEvidence { + return confirmation ?? { + depth: options.confirmationDepth, + satisfied: true, + }; +} + +function nextOperatorCommands(options: CliOptions): string[] { + if (isPilotMode(options.mode)) { + return [ + "Get-Content services/bridge-relayer/out/base8453-pilot-evidence.json", + "Get-Content services/bridge-relayer/out/base8453-pilot-release-evidence.json", + "npm run flowchain:product-e2e", + ]; + } + return [ + "npm run bridge:local-credit:smoke", + "npm run flowchain:product-e2e", + ]; +} + +function replayDecision( + credit: BridgeCredit, + duplicateReplayKeys: `0x${string}`[], + application?: BridgeRuntimeCreditApplication, +): BridgePilotEvidence["replay"]["decision"] { + if (application?.status === "idempotent_replay") { + return "already_applied_idempotent"; + } + if (credit.status === "rejected" && duplicateReplayKeys.includes(credit.replayKey)) { + return "duplicate_replay_key_rejected"; + } + return "accepted_once"; +} + +function makePilotEvidence( + options: CliOptions, + observation: BridgeObservation, + credit: BridgeCredit, + duplicateKeys: `0x${string}`[], + application: BridgeRuntimeCreditApplication | undefined, + confirmation?: BridgeConfirmationEvidence, +): BridgePilotEvidence { + const confirmationEvidence = confirmationForEvidence(options, confirmation ?? observation.guardrails.confirmation); + const appliedExactlyOnce = application?.status === "applied" && application.applyCount === 1; + const approvedContract = observation.guardrails.approvedContract ?? true; + return { + schema: "flowmemory.bridge_pilot_evidence.v0", + evidenceId: stableId("flowmemory.bridge_pilot_evidence.v0", { + observationId: observation.observationId, + creditId: credit.creditId, + applicationId: application?.applicationId ?? null, + replayDecision: replayDecision(credit, duplicateKeys, application), + }), + generatedAt: FIXED_TEST_OBSERVED_AT, + mode: options.mode, + productionReady: false, + localOnly: true, + observationId: observation.observationId, + creditId: credit.creditId, + depositId: observation.deposit.depositId, + replayKey: observation.replayKey, + source: { + chainId: observation.deposit.sourceChainId, + chainIdHex: chainIdHex(observation.deposit.sourceChainId), + contract: observation.deposit.sourceContract, + txHash: observation.deposit.txHash, + logIndex: observation.deposit.logIndex, + blockNumber: observation.deposit.sourceBlockNumber, + }, + guardrails: { + approvedContract, + confirmation: confirmationEvidence, + maxUsd: options.maxUsd, + maxDepositAmount: options.maxDepositAmount, + totalCapAmount: options.totalCapAmount, + pilotModeTag: PILOT_MODE_TAG, + supportedTokens: options.supportedTokens, + operatorAcknowledged: options.acknowledgePilot, + noSecrets: true, + }, + creditApplication: { + applicationId: application?.applicationId, + status: application?.status ?? credit.status, + appliedExactlyOnce, + rejectionReason: application?.rejectionReason ?? credit.rejectionReason, + }, + replay: { + decision: replayDecision(credit, duplicateKeys, application), + duplicateReplayKeys: duplicateKeys, + }, + nextOperatorCommands: nextOperatorCommands(options), + }; +} + +function makeReleaseEvidence(intent: BridgeWithdrawalIntent, deposit: BridgeDeposit): BridgeReleaseEvidence { + const evidenceHash = stableId("flowmemory.bridge_release_evidence_hash.v0", { + withdrawalIntentId: intent.withdrawalIntentId, + creditId: intent.creditId, + depositId: intent.depositId, + amount: intent.amount, + baseRecipient: intent.baseRecipient, + }); + return { + schema: "flowmemory.bridge_release_evidence.v0", + releaseEvidenceId: stableId("flowmemory.bridge_release_evidence.v0", { + withdrawalIntentId: intent.withdrawalIntentId, + evidenceHash, + }), + generatedAt: FIXED_TEST_OBSERVED_AT, + withdrawalIntentId: intent.withdrawalIntentId, + creditId: intent.creditId, + depositId: intent.depositId, + sourceChainId: intent.sourceChainId, + destinationChainId: intent.destinationChainId, + lockbox: deposit.sourceContract, + releaseCall: { + method: deposit.token === "0x0000000000000000000000000000000000000000" ? "releaseNative" : "releaseERC20", + recipient: intent.baseRecipient, + token: intent.token, + amount: intent.amount, + evidenceHash, + broadcast: false, + }, + operatorNote: "Pilot release evidence only. Review before any separate release-authority transaction; this relayer does not broadcast.", + productionReady: false, + localOnly: true, + }; +} + function addressFromAbiWord(word: `0x${string}`, name: string): `0x${string}` { return asAddress(`0x${word.slice(-40)}`, name); } +function assertApprovedLockbox(address: `0x${string}`, options: CliOptions): void { + if (options.approvedLockboxAddresses.length === 0) { + return; + } + const approved = new Set(options.approvedLockboxAddresses.map((entry) => entry.toLowerCase())); + if (!approved.has(address.toLowerCase())) { + throw new Error(`unapproved bridge lockbox address: ${address}`); + } +} + +function assertSupportedToken(address: `0x${string}`, options: CliOptions): void { + if (options.supportedTokens.length === 0) { + return; + } + const supported = new Set(options.supportedTokens.map((entry) => entry.toLowerCase())); + if (!supported.has(address.toLowerCase())) { + throw new Error(`unsupported bridge token for pilot: ${address}`); + } +} + +function enforcePilotDepositGuardrails(deposits: BridgeDeposit[], options: CliOptions): void { + if (!isPilotMode(options.mode)) { + return; + } + const maxDeposit = BigInt(options.maxDepositAmount ?? "0"); + const totalCap = BigInt(options.totalCapAmount ?? "0"); + let total = 0n; + const countedReplayKeys = new Set<`0x${string}`>(); + for (const deposit of deposits) { + if (deposit.sourceChainId !== BASE_MAINNET_CHAIN_ID) { + throw new Error(`pilot deposit must be from Base chain ${BASE_MAINNET_CHAIN_ID} (${BASE_MAINNET_CHAIN_ID_HEX})`); + } + if (deposit.flowchainRecipient === ZERO_BYTES32) { + throw new Error("pilot deposit is missing a local FlowChain recipient"); + } + assertApprovedLockbox(deposit.sourceContract, options); + assertSupportedToken(deposit.token, options); + const amount = BigInt(deposit.amount); + if (amount > maxDeposit) { + throw new Error(`pilot deposit amount exceeds --max-deposit-amount: ${deposit.amount}`); + } + const replayKey = bridgeReplayKey(deposit); + if (!countedReplayKeys.has(replayKey)) { + countedReplayKeys.add(replayKey); + total += amount; + } + } + if (total > totalCap) { + throw new Error(`pilot deposit batch exceeds --total-cap-amount: ${total.toString()}`); + } +} + export function parseBridgeDepositLog(log: RpcLog, expectedChainId: BridgeSourceChainId): BridgeDeposit { if (log.removed) { throw new Error("removed bridge logs must be handled by a reorg-aware reader"); } - if (log.topics[0]?.toLowerCase() !== BRIDGE_DEPOSIT_TOPIC0) { + const topic0 = log.topics[0]?.toLowerCase(); + if (topic0 !== BRIDGE_DEPOSIT_TOPIC0 && topic0 !== BRIDGE_DEPOSIT_LEGACY_TOPIC0) { throw new Error("log is not a BaseBridgeLockbox BridgeDeposit event"); } const data = hexToBytes(log.data); - if (data.length !== 5 * 32) { - throw new Error(`BridgeDeposit log data must contain 5 ABI words, got ${data.length / 32}`); + if (data.length !== 5 * 32 && data.length !== 7 * 32) { + throw new Error(`BridgeDeposit log data must contain 5 or 7 ABI words, got ${data.length / 32}`); } const eventChainId = asSourceChainId(Number(BigInt(asHash(log.topics[2] ?? "", "sourceChainId"))), "sourceChainId"); @@ -832,6 +1441,18 @@ export function parseBridgeDepositLog(log: RpcLog, expectedChainId: BridgeSource throw new Error(`BridgeDeposit event chain id mismatch: expected ${expectedChainId}, got ${eventChainId}`); } + const hasExtendedEventFields = data.length === 7 * 32; + const eventLockbox = hasExtendedEventFields + ? addressFromAbiWord(decodeBytes32Word(data, 0), "lockbox") + : asAddress(log.address, "sourceContract"); + if (eventLockbox.toLowerCase() !== log.address.toLowerCase()) { + throw new Error(`BridgeDeposit lockbox mismatch: emitter ${log.address}, event ${eventLockbox}`); + } + const tokenOffset = hasExtendedEventFields ? 1 : 0; + if (hasExtendedEventFields && decodeBytes32Word(data, 6) !== PILOT_MODE_TAG) { + throw new Error("BridgeDeposit pilot mode tag mismatch"); + } + return { schema: "flowmemory.bridge_deposit.v0", depositId: asHash(log.topics[1] ?? "", "depositId"), @@ -842,17 +1463,17 @@ export function parseBridgeDepositLog(log: RpcLog, expectedChainId: BridgeSource sourceBlockNumber: hexQuantityToDecimalString(log.blockNumber, "blockNumber"), sourceBlockHash: log.blockHash === undefined ? undefined : asHash(log.blockHash, "blockHash"), transactionIndex: hexQuantityToNumber(log.transactionIndex, "transactionIndex"), - token: addressFromAbiWord(decodeBytes32Word(data, 0), "token"), - amount: decodeUint256Word(data, 1).toString(), + token: addressFromAbiWord(decodeBytes32Word(data, tokenOffset), "token"), + amount: decodeUint256Word(data, tokenOffset + 1).toString(), sender: asAddress(decodeAddressTopic(log.topics[3] ?? ""), "sender"), - flowchainRecipient: decodeBytes32Word(data, 2), - nonce: decodeUint256Word(data, 3).toString(), - metadataHash: decodeBytes32Word(data, 4), + flowchainRecipient: decodeBytes32Word(data, tokenOffset + 2), + nonce: decodeUint256Word(data, tokenOffset + 3).toString(), + metadataHash: decodeBytes32Word(data, tokenOffset + 4), status: "observed", }; } -async function rpcCall(rpcUrl: string, method: string, params: JsonValue[]): Promise { +export async function rpcCall(rpcUrl: string, method: string, params: JsonValue[]): Promise { const response = await fetch(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, @@ -869,37 +1490,94 @@ function blockTag(value: string): string { return `0x${BigInt(value).toString(16)}`; } -async function readChainId(rpcUrl: string): Promise { +export async function readChainId(rpcUrl: string): Promise { const result = await rpcCall(rpcUrl, "eth_chainId", []); return Number(BigInt(result)); } -async function readBridgeDepositLogs(options: CliOptions): Promise { +export async function readLatestBlockNumber(rpcUrl: string): Promise { + const result = await rpcCall(rpcUrl, "eth_blockNumber", []); + return hexQuantityToBigInt(result, "eth_blockNumber"); +} + +interface BridgeLogReadResult { + deposits: BridgeDeposit[]; + confirmation?: BridgeConfirmationEvidence; +} + +async function readBridgeDepositLogs(options: CliOptions): Promise { const expectedChainId = expectedChainIdForMode(options.mode, options.expectedChainId); + if (options.lockboxAddress !== undefined) { + assertApprovedLockbox(options.lockboxAddress, options); + } const actualChainId = await readChainId(options.rpcUrl ?? ""); if (actualChainId !== expectedChainId) { - throw new Error(`wrong chain id: expected ${expectedChainId}, got ${actualChainId}`); + throw new Error(`wrong chain id: expected ${expectedChainId} (${chainIdHex(expectedChainId)}), got ${actualChainId} (${chainIdHex(actualChainId as BridgeSourceChainId)})`); + } + + let confirmation: BridgeConfirmationEvidence | undefined; + if (options.confirmationDepth > 0) { + const latestBlock = await readLatestBlockNumber(options.rpcUrl ?? ""); + const requestedToBlock = asBlock(options.toBlock ?? "0", "--to-block"); + const depth = BigInt(options.confirmationDepth); + const requiredConfirmedBlock = latestBlock >= depth ? latestBlock - depth : -1n; + const satisfied = requiredConfirmedBlock >= requestedToBlock; + confirmation = { + depth: options.confirmationDepth, + latestBlockNumber: latestBlock.toString(), + requiredConfirmedBlockNumber: requiredConfirmedBlock < 0n ? "0" : requiredConfirmedBlock.toString(), + requestedToBlock: requestedToBlock.toString(), + satisfied, + }; + if (!satisfied) { + throw new Error(`insufficient confirmations: toBlock ${requestedToBlock.toString()} requires depth ${options.confirmationDepth}, latest block ${latestBlock.toString()}`); + } } const logs = await rpcCall(options.rpcUrl ?? "", "eth_getLogs", [{ address: options.lockboxAddress, fromBlock: blockTag(options.fromBlock ?? "0"), toBlock: blockTag(options.toBlock ?? "0"), - topics: [BRIDGE_DEPOSIT_TOPIC0], + topics: [[BRIDGE_DEPOSIT_TOPIC0, BRIDGE_DEPOSIT_LEGACY_TOPIC0]], }]); - return logs + const deposits = logs .filter((log) => !log.removed) .map((log) => parseBridgeDepositLog(log, expectedChainId)); + for (const deposit of deposits) { + assertApprovedLockbox(deposit.sourceContract, options); + assertSupportedToken(deposit.token, options); + } + return { deposits, confirmation }; } export async function runBridgePipeline(options: CliOptions): Promise { - const deposits = options.mode === "mock" - ? fixtureDeposits(JSON.parse(readFileSync(resolve(options.fixturePath ?? ""), "utf8")) as unknown) + const readResult = isMockMode(options.mode) + ? { + deposits: fixtureDeposits(JSON.parse(readFileSync(resolve(options.fixturePath ?? ""), "utf8")) as unknown), + confirmation: undefined, + } : await readBridgeDepositLogs(options); + const deposits = readResult.deposits; + enforcePilotDepositGuardrails(deposits, options); + if (options.mode === "base-mainnet-canary" && (options.applyCredit || options.withdrawalIntent)) { + throw new Error("Base mainnet canary is read-only; use base-mainnet-pilot only after explicit pilot approval"); + } - const observations = deposits.map((deposit) => makeObservation(deposit, options.mode, options.maxUsd)); + const confirmation = confirmationForEvidence(options, readResult.confirmation); + const approvedContracts = new Set(options.approvedLockboxAddresses.map((address) => address.toLowerCase())); + const observations = deposits.map((deposit) => makeObservation(deposit, options.mode, { + maxUsd: options.maxUsd, + maxDepositAmount: options.maxDepositAmount, + totalCapAmount: options.totalCapAmount, + supportedTokens: options.supportedTokens.length === 0 ? undefined : options.supportedTokens, + confirmation: isPilotMode(options.mode) || options.confirmationDepth > 0 ? confirmation : undefined, + approvedContract: options.approvedLockboxAddresses.length === 0 + ? undefined + : approvedContracts.has(deposit.sourceContract.toLowerCase()), + })); const credits = makeCredits(observations, options.applyCredit); + const runtimeApplications = applyCreditsExactlyOnce(credits, options.runtimeStatePath); const withdrawalIntents = options.withdrawalIntent ? credits .filter((credit) => credit.status === "applied") @@ -911,12 +1589,42 @@ export async function runBridgePipeline(options: CliOptions): Promise { + const deposit = observations.find((observation) => observation.deposit.depositId === intent.depositId)?.deposit; + if (deposit === undefined) { + throw new Error(`missing deposit for withdrawal intent ${intent.withdrawalIntentId}`); + } + return makeReleaseEvidence(intent, deposit); + }); + const duplicateKeys = duplicateReplayKeys(observations); + const pilotEvidence = isPilotMode(options.mode) + ? observations.map((observation, index) => { + const credit = credits[index]; + if (credit === undefined) { + throw new Error(`missing credit for observation ${observation.observationId}`); + } + const application = runtimeApplications[index] ?? runtimeApplications.find((candidate) => candidate.creditId === credit.creditId); + return makePilotEvidence(options, observation, credit, duplicateKeys, application, confirmation); + }) + : []; + const handoff = makeRuntimeHandoff( + options.mode, + observations, + credits, + withdrawalIntents, + runtimeApplications, + pilotEvidence, + releaseEvidences, + options.handoffOutPath, + ); return { observations, credits, withdrawalIntents, + runtimeApplications, + pilotEvidence, + releaseEvidences, handoff, }; } @@ -933,6 +1641,7 @@ export async function runBridgeObserver(options: CliOptions): Promise(values: TSingle[], setValue: TSet } function printRunBoundary(options: CliOptions): void { - if (options.mode === "mock") { + if (isMockMode(options.mode)) { console.log("Bridge mode: mock fixture; no chain RPC or private key is used."); + if (options.mode === "mock-pilot") { + console.log(`Pilot source chain: Base mainnet ${BASE_MAINNET_CHAIN_ID} (${BASE_MAINNET_CHAIN_ID_HEX}).`); + console.log(`Approved lockboxes: ${options.approvedLockboxAddresses.join(", ")}`); + console.log(`Supported tokens: ${options.supportedTokens.join(", ")}`); + console.log(`Pilot max USD: ${options.maxUsd}; max deposit amount: ${options.maxDepositAmount}; total cap amount: ${options.totalCapAmount}.`); + } return; } const expectedChainId = expectedChainIdForMode(options.mode, options.expectedChainId); console.log(`Bridge mode: ${options.mode}`); - console.log(`Chain id: ${expectedChainId}`); + console.log(`Chain id: ${expectedChainId} (${chainIdHex(expectedChainId)})`); console.log(`Lockbox: ${options.lockboxAddress}`); console.log(`Block range: ${options.fromBlock}-${options.toBlock}`); + console.log(`Confirmation depth: ${options.confirmationDepth}`); console.log("Broadcast: false; this observer never sends transactions."); if (options.mode === "base-sepolia") { console.log("Asset boundary: Base Sepolia test assets only."); @@ -959,6 +1675,11 @@ function printRunBoundary(options: CliOptions): void { if (options.mode === "base-mainnet-canary") { console.log(`Real-funds guardrail acknowledged for read-only canary. Max USD: ${options.maxUsd}`); } + if (options.mode === "base-mainnet-pilot") { + console.log(`Base pilot acknowledged. Approved lockboxes: ${options.approvedLockboxAddresses.join(", ")}`); + console.log(`Supported tokens: ${options.supportedTokens.join(", ")}`); + console.log(`Pilot max USD: ${options.maxUsd}; max deposit amount: ${options.maxDepositAmount}; total cap amount: ${options.totalCapAmount}.`); + } } if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { @@ -979,14 +1700,41 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1 if (options.handoffOutPath !== undefined) { writeJson(options.handoffOutPath, result.handoff); } + if (options.evidenceOutPath !== undefined) { + writeJson( + options.evidenceOutPath, + artifactForSingleOrSet(result.pilotEvidence, { + schema: "flowmemory.bridge_pilot_evidence_set.v0", + generatedAt: FIXED_TEST_OBSERVED_AT, + count: result.pilotEvidence.length, + evidence: result.pilotEvidence, + productionReady: false, + }), + ); + } if (options.withdrawalOutPath !== undefined) { writeJson( options.withdrawalOutPath, artifactForSingleOrSet(result.withdrawalIntents, makeWithdrawalIntentSet(result.withdrawalIntents)), ); } + if (options.releaseEvidenceOutPath !== undefined) { + writeJson( + options.releaseEvidenceOutPath, + artifactForSingleOrSet(result.releaseEvidences, { + schema: "flowmemory.bridge_release_evidence_set.v0", + generatedAt: FIXED_TEST_OBSERVED_AT, + count: result.releaseEvidences.length, + releaseEvidences: result.releaseEvidences, + productionReady: false, + }), + ); + } console.log( - `Bridge run complete: observed=${result.observations.length}, credits=${result.credits.length}, withdrawals=${result.withdrawalIntents.length}`, + `Bridge run complete: observed=${result.observations.length}, credits=${result.credits.length}, applications=${result.runtimeApplications.length}, withdrawals=${result.withdrawalIntents.length}, evidence=${result.pilotEvidence.length}`, ); + for (const command of nextOperatorCommands(options)) { + console.log(`Next operator command: ${command}`); + } } diff --git a/services/bridge-relayer/test/bridge-relayer.test.ts b/services/bridge-relayer/test/bridge-relayer.test.ts index e66ae1ff..248ab406 100644 --- a/services/bridge-relayer/test/bridge-relayer.test.ts +++ b/services/bridge-relayer/test/bridge-relayer.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; @@ -7,6 +9,7 @@ import Ajv2020 from "ajv/dist/2020.js"; import { canonicalJson } from "../../shared/src/index.ts"; import { + BASE_MAINNET_CHAIN_ID, BASE_SEPOLIA_CHAIN_ID, BRIDGE_DEPOSIT_TOPIC0, FIXED_TEST_OBSERVED_AT, @@ -19,8 +22,12 @@ import { runBridgePipeline, validateDeposit, } from "../src/observe-base-lockbox.ts"; +import { runRelayOnce, type RelayMonitorOptions } from "../src/base8453-relay-monitor.ts"; +import { diagnoseTx, LOCK_NATIVE_SELECTOR } from "../src/base8453-tx-diagnostic.ts"; const fixtureUrl = new URL("../../../fixtures/bridge/base-sepolia-mock-deposit.json", import.meta.url); +const pilotFixtureUrl = new URL("../../../fixtures/bridge/base8453-pilot-mock-deposit.json", import.meta.url); +const pilotDuplicateFixtureUrl = new URL("../../../fixtures/bridge/base8453-pilot-duplicate-mock-deposits.json", import.meta.url); function readSchema(name: string) { return JSON.parse(readFileSync(new URL(`../../../schemas/flowmemory/${name}`, import.meta.url), "utf8")) as object; @@ -34,8 +41,14 @@ function bridgeAjv(): Ajv2020 { "bridge-observation-set.schema.json", "bridge-credit.schema.json", "bridge-credit-set.schema.json", + "bridge-runtime-credit-application.schema.json", + "bridge-runtime-credit-application-state.schema.json", "bridge-withdrawal-intent.schema.json", "bridge-withdrawal-intent-set.schema.json", + "bridge-withdrawal-authorization.schema.json", + "bridge-pilot-evidence.schema.json", + "bridge-release-evidence.schema.json", + "bridge-local-usage-proof.schema.json", "bridge-runtime-handoff.schema.json", ].forEach((name) => ajv.addSchema(readSchema(name), name)); return ajv; @@ -63,26 +76,29 @@ function dataWord(value: string | bigint): string { return value.slice(2).padStart(64, "0"); } -function sampleBridgeDepositLog() { +function sampleBridgeDepositLog(chainId = BASE_SEPOLIA_CHAIN_ID, address = "0x1111111111111111111111111111111111111111") { const sender = "0x4444444444444444444444444444444444444444"; const token = "0x3333333333333333333333333333333333333333"; const recipient = "0x5555555555555555555555555555555555555555555555555555555555555555"; const metadataHash = "0x6666666666666666666666666666666666666666666666666666666666666666"; + const pilotModeTag = "0x8edc10ba20d09d2f920c2135ea53baaa72ec90df339d57248f096ca150771a6e"; return { - address: "0x1111111111111111111111111111111111111111", + address, topics: [ BRIDGE_DEPOSIT_TOPIC0, "0x7777777777777777777777777777777777777777777777777777777777777777", - topic(BigInt(BASE_SEPOLIA_CHAIN_ID)), + topic(BigInt(chainId)), addressTopic(sender), ], data: `0x${[ + dataWord(address), dataWord(token), dataWord(20_000_000n), dataWord(recipient), dataWord(7n), dataWord(metadataHash), + dataWord(pilotModeTag), ].join("")}`, blockNumber: "0x64", blockHash: "0x9999999999999999999999999999999999999999999999999999999999999999", @@ -149,6 +165,214 @@ test("local credit smoke pipeline applies a mock credit and records test withdra assert.equal(result.credits[0]?.status, "applied"); assert.equal(result.withdrawalIntents[0]?.status, "requested"); assert.equal(result.handoff.generatedAt, FIXED_TEST_OBSERVED_AT); + validateSchema("bridge-runtime-handoff.schema.json", result.handoff); +}); + +test("mock pilot E2E path applies a Base 8453 credit exactly once across replay", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "flowmemory-bridge-pilot-")); + const statePath = join(stateDir, "credit-state.json"); + try { + const args = [ + "--mode", + "mock-pilot", + "--fixture", + fileURLToPath(pilotFixtureUrl), + "--approved-lockbox", + "0x1111111111111111111111111111111111111111", + "--confirmations", + "2", + "--acknowledge-pilot", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--supported-token", + "0x3333333333333333333333333333333333333333", + "--apply-credit", + "--withdrawal-intent", + "--runtime-state", + statePath, + ]; + const firstRun = await runBridgePipeline(parseBridgeArgs(args)); + const replayRun = await runBridgePipeline(parseBridgeArgs(args)); + + assert.equal(firstRun.observations[0]?.deposit.sourceChainId, BASE_MAINNET_CHAIN_ID); + assert.equal(firstRun.credits[0]?.status, "applied"); + assert.equal(firstRun.runtimeApplications[0]?.status, "applied"); + assert.equal(firstRun.runtimeApplications[0]?.applyCount, 1); + assert.equal(firstRun.pilotEvidence[0]?.source.chainIdHex, "0x2105"); + assert.equal(firstRun.pilotEvidence[0]?.creditApplication.appliedExactlyOnce, true); + assert.equal(firstRun.releaseEvidences[0]?.releaseCall.broadcast, false); + assert.equal(replayRun.credits[0]?.status, "rejected"); + assert.equal(replayRun.credits[0]?.rejectionReason, "already_applied_replay_key"); + assert.equal(replayRun.runtimeApplications[0]?.status, "idempotent_replay"); + assert.equal(replayRun.runtimeApplications[0]?.applyCount, 0); + assert.equal(replayRun.withdrawalIntents.length, 0); + validateSchema("bridge-runtime-credit-application.schema.json", firstRun.runtimeApplications[0]); + validateSchema("bridge-pilot-evidence.schema.json", firstRun.pilotEvidence[0]); + validateSchema("bridge-release-evidence.schema.json", firstRun.releaseEvidences[0]); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("low-latency relay keeps READY status across duplicate idempotent replay", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "flowmemory-bridge-relay-")); + const relayOptions: RelayMonitorOptions = { + mode: "mock-pilot", + fixturePath: fileURLToPath(pilotFixtureUrl), + lockboxAddress: "0x1111111111111111111111111111111111111111", + approvedLockboxAddress: "0x1111111111111111111111111111111111111111", + supportedTokens: ["0x3333333333333333333333333333333333333333"], + latestBlockOverride: "112", + confirmations: 12, + pollMs: 5_000, + maxScanBlocks: 500n, + recoveryWindowBlocks: 128n, + maxUsd: "1", + maxDepositAmount: "20000000", + totalCapAmount: "40000000", + checkpointPath: join(stateDir, "checkpoint.json"), + statusOutPath: join(stateDir, "status.json"), + reportOutPath: join(stateDir, "relay-report.json"), + handoffOutPath: join(stateDir, "handoff.json"), + runtimeStatePath: join(stateDir, "relay-credit-state.json"), + nodeStatePath: join(stateDir, "node-state.json"), + nodeDir: join(stateDir, "node"), + nodeSubmitMode: "direct", + nodeWaitMs: 5_000, + monitor: false, + iterations: 1, + acknowledgePilot: true, + acknowledgeRealFunds: true, + }; + try { + const firstRun = await runRelayOnce(relayOptions); + const replayRun = await runRelayOnce(relayOptions); + + assert.equal(firstRun.status.overallStatus, "READY"); + assert.equal(firstRun.status.summary.creditAppliedToRunningL1Node, 1); + assert.equal(firstRun.status.summary.duplicateIdempotentReplay, 0); + assert.equal(replayRun.status.overallStatus, "READY"); + assert.equal(replayRun.status.previousReadyPreserved, true); + assert.equal(replayRun.status.summary.duplicateIdempotentReplay, 1); + assert.equal(replayRun.status.summary.invalidDirectTransferOrReverted, 0); + assert.equal(replayRun.report.counts.applied, 0); + assert.equal(replayRun.report.counts.idempotent, 1); + assert.equal(replayRun.handoff.runtimeApplications[0]?.status, "idempotent_replay"); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("mock pilot duplicate deposits reject replay with explicit evidence", async () => { + const result = await runBridgePipeline(parseBridgeArgs([ + "--mode", + "mock-pilot", + "--fixture", + fileURLToPath(pilotDuplicateFixtureUrl), + "--approved-lockbox", + "0x1111111111111111111111111111111111111111", + "--confirmations", + "2", + "--acknowledge-pilot", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "40000000", + "--supported-token", + "0x3333333333333333333333333333333333333333", + "--apply-credit", + "--withdrawal-intent", + ])); + + assert.equal(result.observations.length, 2); + assert.equal(result.credits[0]?.status, "applied"); + assert.equal(result.credits[1]?.status, "rejected"); + assert.equal(result.credits[1]?.rejectionReason, "duplicate_replay_key"); + assert.equal(result.handoff.replayProtection.duplicateReplayKeys.length, 1); + assert.equal(result.pilotEvidence[1]?.replay.decision, "duplicate_replay_key_rejected"); + assert.equal(result.withdrawalIntents.length, 1); +}); + +test("mock pilot rejects wrong source chains and unapproved contracts", async () => { + await assert.rejects( + () => runBridgePipeline(parseBridgeArgs([ + "--mode", + "mock-pilot", + "--fixture", + fileURLToPath(fixtureUrl), + "--approved-lockbox", + "0x1111111111111111111111111111111111111111", + "--confirmations", + "2", + "--acknowledge-pilot", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--supported-token", + "0x3333333333333333333333333333333333333333", + "--apply-credit", + ])), + /pilot deposit must be from Base chain 8453/, + ); + + await assert.rejects( + () => runBridgePipeline(parseBridgeArgs([ + "--mode", + "mock-pilot", + "--fixture", + fileURLToPath(pilotFixtureUrl), + "--approved-lockbox", + "0x9999999999999999999999999999999999999999", + "--confirmations", + "2", + "--acknowledge-pilot", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--supported-token", + "0x3333333333333333333333333333333333333333", + "--apply-credit", + ])), + /unapproved bridge lockbox address/, + ); +}); + +test("mock pilot rejects unsupported tokens", async () => { + await assert.rejects( + () => runBridgePipeline(parseBridgeArgs([ + "--mode", + "mock-pilot", + "--fixture", + fileURLToPath(pilotFixtureUrl), + "--approved-lockbox", + "0x1111111111111111111111111111111111111111", + "--confirmations", + "2", + "--acknowledge-pilot", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--supported-token", + "0x9999999999999999999999999999999999999999", + "--apply-credit", + ])), + /unsupported bridge token for pilot/, + ); }); test("decodes BaseBridgeLockbox BridgeDeposit logs from RPC log payloads", () => { @@ -210,6 +434,334 @@ test("observes Base Sepolia deposit logs through read-only RPC calls", async () } }); +test("observes Base public-network pilot only after eth_chainId 0x2105 and confirmation depth", async () => { + const calls: string[] = []; + const stateDir = mkdtempSync(join(tmpdir(), "flowmemory-bridge-pilot-rpc-")); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { method: string }; + calls.push(body.method); + if (body.method === "eth_chainId") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x2105" }), { + headers: { "content-type": "application/json" }, + }); + } + if (body.method === "eth_blockNumber") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x70" }), { + headers: { "content-type": "application/json" }, + }); + } + if (body.method === "eth_getLogs") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: [sampleBridgeDepositLog(BASE_MAINNET_CHAIN_ID)] }), { + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, error: { message: "unexpected method" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await runBridgePipeline(parseBridgeArgs([ + "--mode", + "base-mainnet-pilot", + "--rpc-url", + "https://example.invalid/base-mainnet", + "--lockbox-address", + "0x1111111111111111111111111111111111111111", + "--approved-lockbox", + "0x1111111111111111111111111111111111111111", + "--from-block", + "100", + "--to-block", + "100", + "--confirmations", + "5", + "--acknowledge-pilot", + "--acknowledge-real-funds", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--supported-token", + "0x3333333333333333333333333333333333333333", + "--apply-credit", + "--withdrawal-intent", + "--runtime-state", + join(stateDir, "credit-state.json"), + ])); + + assert.deepEqual(calls, ["eth_chainId", "eth_blockNumber", "eth_getLogs"]); + assert.equal(result.observations.length, 1); + assert.equal(result.observations[0]?.deposit.sourceChainId, BASE_MAINNET_CHAIN_ID); + assert.equal(result.observations[0]?.guardrails.confirmation?.depth, 5); + assert.equal(result.observations[0]?.guardrails.confirmation?.satisfied, true); + assert.equal(result.credits[0]?.status, "applied"); + assert.equal(result.pilotEvidence[0]?.source.chainIdHex, "0x2105"); + } finally { + globalThis.fetch = originalFetch; + rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test("Base public-network pilot rejects wrong chain IDs before log reads", async () => { + const calls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { method: string }; + calls.push(body.method); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x14a34" }), { + headers: { "content-type": "application/json" }, + }); + }; + + try { + await assert.rejects( + () => runBridgePipeline(parseBridgeArgs([ + "--mode", + "base-mainnet-pilot", + "--rpc-url", + "https://example.invalid/base-mainnet", + "--lockbox-address", + "0x1111111111111111111111111111111111111111", + "--approved-lockbox", + "0x1111111111111111111111111111111111111111", + "--confirmations", + "2", + "--from-block", + "100", + "--to-block", + "100", + "--acknowledge-pilot", + "--acknowledge-real-funds", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--supported-token", + "0x3333333333333333333333333333333333333333", + ])), + /wrong chain id: expected 8453 \(0x2105\)/, + ); + assert.deepEqual(calls, ["eth_chainId"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Base public-network pilot rejects unapproved lockbox and insufficient confirmations", async () => { + await assert.rejects( + () => runBridgePipeline(parseBridgeArgs([ + "--mode", + "base-mainnet-pilot", + "--rpc-url", + "https://example.invalid/base-mainnet", + "--lockbox-address", + "0x1111111111111111111111111111111111111111", + "--approved-lockbox", + "0x9999999999999999999999999999999999999999", + "--confirmations", + "2", + "--from-block", + "100", + "--to-block", + "100", + "--acknowledge-pilot", + "--acknowledge-real-funds", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--supported-token", + "0x3333333333333333333333333333333333333333", + ])), + /unapproved bridge lockbox address/, + ); + + const calls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { method: string }; + calls.push(body.method); + if (body.method === "eth_chainId") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x2105" }), { + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x65" }), { + headers: { "content-type": "application/json" }, + }); + }; + + try { + await assert.rejects( + () => runBridgePipeline(parseBridgeArgs([ + "--mode", + "base-mainnet-pilot", + "--rpc-url", + "https://example.invalid/base-mainnet", + "--lockbox-address", + "0x1111111111111111111111111111111111111111", + "--approved-lockbox", + "0x1111111111111111111111111111111111111111", + "--from-block", + "100", + "--to-block", + "100", + "--confirmations", + "5", + "--acknowledge-pilot", + "--acknowledge-real-funds", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--supported-token", + "0x3333333333333333333333333333333333333333", + ])), + /insufficient confirmations/, + ); + assert.deepEqual(calls, ["eth_chainId", "eth_blockNumber"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Base 8453 transaction diagnostic distinguishes direct transfers from lockNative deposits", async () => { + const txHash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { method: string; params?: unknown[] }; + if (body.method === "eth_chainId") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x2105" }), { + headers: { "content-type": "application/json" }, + }); + } + if (body.method === "eth_getTransactionReceipt") { + return new Response(JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + status: "0x1", + to: "0x1111111111111111111111111111111111111111", + transactionHash: body.params?.[0], + logs: [], + }, + }), { + headers: { "content-type": "application/json" }, + }); + } + if (body.method === "eth_getTransactionByHash") { + return new Response(JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + hash: body.params?.[0], + to: "0x1111111111111111111111111111111111111111", + input: "0x", + value: "0x1", + }, + }), { + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, error: { message: "unexpected method" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const report = await diagnoseTx({ + rpcUrl: "https://example.invalid/base-mainnet", + txHash, + approvedLockbox: "0x1111111111111111111111111111111111111111", + supportedTokens: ["0x3333333333333333333333333333333333333333"], + maxDepositAmount: "20000000", + totalCapAmount: "40000000", + outPath: "unused.json", + acknowledgePilot: true, + }); + assert.equal(report.classification, "wrong_method_or_direct_transfer"); + assert.equal(report.checks.recipientIsApprovedLockbox, true); + assert.equal(report.checks.methodSelectorIsLockNative, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Base 8453 transaction diagnostic accepts lockNative tx with BridgeDeposit event", async () => { + const txHash = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { method: string; params?: unknown[] }; + if (body.method === "eth_chainId") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x2105" }), { + headers: { "content-type": "application/json" }, + }); + } + if (body.method === "eth_getTransactionReceipt") { + return new Response(JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + status: "0x1", + to: "0x1111111111111111111111111111111111111111", + transactionHash: body.params?.[0], + logs: [sampleBridgeDepositLog(BASE_MAINNET_CHAIN_ID)], + }, + }), { + headers: { "content-type": "application/json" }, + }); + } + if (body.method === "eth_getTransactionByHash") { + return new Response(JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + hash: body.params?.[0], + to: "0x1111111111111111111111111111111111111111", + input: `${LOCK_NATIVE_SELECTOR}${"0".repeat(128)}`, + value: "0x1312d00", + }, + }), { + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, error: { message: "unexpected method" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const report = await diagnoseTx({ + rpcUrl: "https://example.invalid/base-mainnet", + txHash, + approvedLockbox: "0x1111111111111111111111111111111111111111", + supportedTokens: ["0x3333333333333333333333333333333333333333"], + maxDepositAmount: "20000000", + totalCapAmount: "40000000", + outPath: "unused.json", + acknowledgePilot: true, + }); + assert.equal(report.classification, "valid_bridge_deposit"); + assert.equal(report.checks.bridgeDepositEventExists, true); + assert.equal(report.deposit?.depositId, "0x7777777777777777777777777777777777777777777777777777777777777777"); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("requires explicit Base mainnet real-funds guardrails", () => { assert.throws( () => parseBridgeArgs([ diff --git a/tests/bridge/BaseBridgeLockbox.t.sol b/tests/bridge/BaseBridgeLockbox.t.sol index a9a5842c..846f0e73 100644 --- a/tests/bridge/BaseBridgeLockbox.t.sol +++ b/tests/bridge/BaseBridgeLockbox.t.sol @@ -67,6 +67,10 @@ contract BridgeCaller { lockbox.setPaused(paused); } + function setEmergencyStopped(BaseBridgeLockbox lockbox, bool stopped) external { + lockbox.setEmergencyStopped(stopped); + } + function configureToken( BaseBridgeLockbox lockbox, address token, @@ -109,7 +113,7 @@ contract BaseBridgeLockboxTest { BridgeVm private constant vm = BridgeVm(address(uint160(uint256(keccak256("hevm cheat code"))))); bytes32 private constant BRIDGE_DEPOSIT_SIGNATURE = - keccak256("BridgeDeposit(bytes32,uint256,address,address,uint256,bytes32,uint256,bytes32)"); + keccak256("BridgeDeposit(bytes32,uint256,address,address,address,uint256,bytes32,uint256,bytes32,bytes32)"); bytes32 private constant BRIDGE_RELEASE_SIGNATURE = keccak256("BridgeRelease(bytes32,bytes32,address,address,uint256,bytes32)"); bytes32 private constant RECIPIENT = keccak256("flowchain.recipient.alice"); @@ -139,13 +143,14 @@ contract BaseBridgeLockboxTest { } function testOwnerCanConfigureAllowlistedTokenAndReleaseAuthority() public { - (bool allowed, uint256 perDepositCap, uint256 totalCap, uint256 totalLocked) = + (bool allowed, uint256 perDepositCap, uint256 totalCap, uint256 totalLocked, uint256 totalDeposited) = lockbox.tokenConfigs(address(token)); _assertTrue(allowed); _assertTrue(perDepositCap == 25 ether); _assertTrue(totalCap == 100 ether); _assertTrue(totalLocked == 0); + _assertTrue(totalDeposited == 0); lockbox.setReleaseAuthority(address(caller)); _assertTrue(lockbox.releaseAuthority() == address(caller)); @@ -155,6 +160,9 @@ contract BaseBridgeLockboxTest { vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(caller))); caller.setPaused(lockbox, true); + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(caller))); + caller.setEmergencyStopped(lockbox, true); + vm.expectRevert(abi.encodeWithSelector(BaseBridgeLockbox.NotOwner.selector, address(caller))); caller.configureToken(lockbox, address(token), true, 1 ether, 1 ether); @@ -177,7 +185,8 @@ contract BaseBridgeLockboxTest { 10 ether, RECIPIENT, uint256(1), - keccak256("metadata") + keccak256("metadata"), + lockbox.PILOT_MODE_TAG() ) ); _assertTrue(depositId == expectedDepositId); @@ -195,8 +204,9 @@ contract BaseBridgeLockboxTest { _assertTrue(record.metadataHash == keccak256("metadata")); _assertTrue(record.exists); - (,,, uint256 totalLocked) = lockbox.tokenConfigs(address(token)); + (,,, uint256 totalLocked, uint256 totalDeposited) = lockbox.tokenConfigs(address(token)); _assertTrue(totalLocked == 10 ether); + _assertTrue(totalDeposited == 10 ether); _assertBridgeDepositLog(logs[logs.length - 1], depositId, address(token), 10 ether, 1); } @@ -263,8 +273,9 @@ contract BaseBridgeLockboxTest { caller.lockERC20(lockbox, address(token), 1 ether, RECIPIENT); } - function testCannotLowerTotalCapBelowCurrentlyLockedAmount() public { + function testTotalCapIsCumulativeAndCannotBeLoweredBelowHistoricalDeposits() public { caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); + lockbox.releaseERC20(_depositIdFor(address(caller), address(token), 10 ether, uint256(1)), address(caller), address(token), 10 ether, EVIDENCE_HASH); vm.expectRevert( abi.encodeWithSelector(BaseBridgeLockbox.TotalCapExceeded.selector, address(token), 10 ether, 9 ether) @@ -283,6 +294,21 @@ contract BaseBridgeLockboxTest { _assertTrue(lockbox.remainingDepositAmount(depositId) == 9 ether); } + function testEmergencyStopBlocksDepositsAndReleasesUntilOwnerResumes() public { + bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); + lockbox.setEmergencyStopped(true); + + vm.expectRevert(BaseBridgeLockbox.EmergencyStopped.selector); + caller.lockERC20(lockbox, address(token), 1 ether, RECIPIENT); + + vm.expectRevert(BaseBridgeLockbox.EmergencyStopped.selector); + lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); + + lockbox.setEmergencyStopped(false); + lockbox.releaseERC20(depositId, address(caller), address(token), 1 ether, EVIDENCE_HASH); + _assertTrue(lockbox.remainingDepositAmount(depositId) == 9 ether); + } + function testReleaseERC20RequiresExplicitAuthorityAndKnownDeposit() public { bytes32 depositId = caller.lockERC20(lockbox, address(token), 10 ether, RECIPIENT); lockbox.setReleaseAuthority(address(caller)); @@ -372,14 +398,23 @@ contract BaseBridgeLockboxTest { _assertTrue(uint256(log.topics[2]) == block.chainid); _assertTrue(address(uint160(uint256(log.topics[3]))) == address(caller)); - (address eventToken, uint256 amount, bytes32 recipient, uint256 nonce, bytes32 metadataHash) = - abi.decode(log.data, (address, uint256, bytes32, uint256, bytes32)); - + ( + address lockboxAddress, + address eventToken, + uint256 amount, + bytes32 recipient, + uint256 nonce, + bytes32 metadataHash, + bytes32 pilotModeTag + ) = abi.decode(log.data, (address, address, uint256, bytes32, uint256, bytes32, bytes32)); + + _assertTrue(lockboxAddress == address(lockbox)); _assertTrue(eventToken == expectedToken); _assertTrue(amount == expectedAmount); _assertTrue(recipient == RECIPIENT); _assertTrue(nonce == expectedNonce); _assertTrue(metadataHash == keccak256("metadata")); + _assertTrue(pilotModeTag == lockbox.PILOT_MODE_TAG()); } function _assertBridgeReleaseLog( @@ -408,4 +443,25 @@ contract BaseBridgeLockboxTest { revert AssertionFailed(); } } + + function _depositIdFor(address sender, address depositToken, uint256 amount, uint256 nonce) + private + view + returns (bytes32) + { + return keccak256( + abi.encode( + lockbox.BRIDGE_DEPOSIT_SCHEMA_ID(), + block.chainid, + address(lockbox), + sender, + depositToken, + amount, + RECIPIENT, + nonce, + keccak256("metadata"), + lockbox.PILOT_MODE_TAG() + ) + ); + } }