diff --git a/docs/FLOWCHAIN_REAL_VALUE_PILOT.md b/docs/FLOWCHAIN_REAL_VALUE_PILOT.md index 3fdc8d6f..38d58c6e 100644 --- a/docs/FLOWCHAIN_REAL_VALUE_PILOT.md +++ b/docs/FLOWCHAIN_REAL_VALUE_PILOT.md @@ -19,8 +19,8 @@ approval. ## Current Baseline -Current `main` after PR #143 merged at -`a16fb9a7ce817b8c32d4641610c35e559a6c444c`: +Current `main` after PR #144 merged at +`6272bf1f41761ddd5cb80a0b780fd000d74b5026`: - `npm run flowchain:product-e2e` exists as the local product testnet gate. - `npm run flowchain:full-smoke` exists as the private/local L1 baseline gate. @@ -33,6 +33,8 @@ Current `main` after PR #143 merged at after PR #142 merged. - `npm run flowchain:real-value-pilot:wallet` exists on `main` after PR #143 merged. +- `npm run flowchain:real-value-pilot:ops` exists on `main` after PR #144 + merged. GitHub source-of-truth state checked for this pass: @@ -45,8 +47,9 @@ GitHub source-of-truth state checked for this pass: proof command. - Issue #136 is closed; PR #143 merged the wallet/operator pilot proof command. -- Issues #133, #138, #134, and #135 remain the open subsystem proof blockers - for strict pilot-gate pass. +- Issue #135 is closed; PR #144 merged the ops/installer pilot proof command. +- Issues #133, #138, and #134 remain the open subsystem proof blockers for + strict pilot-gate pass. ## Final Gate @@ -73,7 +76,7 @@ pilot go. Until then, missing proof rows are blockers, not warnings. ## Ops Command Surface -The ops proof command is branch-local until issue #135 merges: +The ops proof command exists on `main` after PR #144: ```powershell npm run flowchain:real-value-pilot:ops @@ -136,18 +139,18 @@ the proof is branch-local or verified from `main`. | --- | --- | --- | --- | | Existing product testnet gate remains green. | HQ/Ops | `npm run flowchain:product-e2e` | Existing command; run before PR when practical. | | L1 baseline gate remains green. | HQ/Ops | `npm run flowchain:l1-e2e` | Exists on `main` as current alias to `flowchain:full-smoke`; latest local main-equivalent run passed. | -| Base chain ID `8453` is verified before any live observer or deployment action. | Contracts + Bridge + Ops | `npm run flowchain:real-value-pilot:contracts`; `npm run flowchain:real-value-pilot:bridge`; `npm run flowchain:real-value-pilot:ops` | Contracts and bridge commands are still missing; ops branch command added here pending PR merge. | -| Lockbox address is loaded from ignored local config or env, not hardcoded as a blanket endorsement. | Contracts + Ops | `npm run flowchain:real-value-pilot:contracts`; `npm run flowchain:real-value-pilot:ops` | Contracts command is still missing; ops branch command added here pending PR merge. | +| Base chain ID `8453` is verified before any live observer or deployment action. | Contracts + Bridge + Ops | `npm run flowchain:real-value-pilot:contracts`; `npm run flowchain:real-value-pilot:bridge`; `npm run flowchain:real-value-pilot:ops` | Contracts command is still missing; bridge branch command added here pending PR merge; ops is merged. | +| Lockbox address is loaded from ignored local config or env, not hardcoded as a blanket endorsement. | Contracts + Ops | `npm run flowchain:real-value-pilot:contracts`; `npm run flowchain:real-value-pilot:ops` | Contracts command is still missing; ops is merged. | | Per-deposit cap, total pilot cap, supported-asset allowlist, pause, release, recovery, and replay protection are covered by tests and dry-run deployment evidence. | Contracts | `npm run flowchain:real-value-pilot:contracts` | Missing dedicated pilot command. | -| Deposit observation writes deterministic observation, credit, and evidence files. | Bridge relayer | `npm run flowchain:real-value-pilot:bridge` | Missing dedicated pilot command. | -| Duplicate Base event replay is rejected or idempotent with explicit evidence. | Bridge relayer + Chain runtime | `npm run flowchain:real-value-pilot:bridge`; `npm run flowchain:real-value-pilot:runtime` | Missing dedicated pilot commands. | +| Deposit observation writes deterministic observation, credit, and evidence files. | Bridge relayer | `npm run flowchain:real-value-pilot:bridge` | Branch command added here; local proof passes, pending PR merge. | +| Duplicate Base event replay is rejected or idempotent with explicit evidence. | Bridge relayer + Chain runtime | `npm run flowchain:real-value-pilot:bridge`; `npm run flowchain:real-value-pilot:runtime` | Bridge branch command added here; runtime command still missing. | | Local runtime applies each pilot bridge credit exactly once and preserves state across restart/export/import. | Chain runtime | `npm run flowchain:real-value-pilot:runtime` | Missing dedicated pilot command. | | Operator wallet can sign pilot acknowledgements, withdrawal intents, release evidence, and emergency messages without committing secrets. | Wallet/operator | `npm run flowchain:real-value-pilot:wallet` | Merged on `main` by PR #143; latest local main-equivalent proof passed. | | Wallet verification rejects wrong chain ID, wrong contract, wrong operator, mutated payload, replay nonce, expired message, and missing cap fields. | Wallet/operator | `npm run flowchain:real-value-pilot:wallet` | Merged on `main` by PR #143; latest local main-equivalent proof passed. | | API exposes pilot status, observations, credits, withdrawal intents, release evidence, cap status, pause status, retry state, and emergency state. | Control plane/dashboard | `npm run flowchain:real-value-pilot:control-dashboard` | Merged on `main` by PR #142; latest local main-equivalent proof passed. | | Dashboard labels the flow as capped owner testing and shows live/degraded/error state plus exact next operator commands. | Control plane/dashboard | `npm run flowchain:real-value-pilot:control-dashboard` | Merged on `main` by PR #142; latest local main-equivalent proof passed. | | Browser stores no private keys or RPC credentials. | Control plane/dashboard + Wallet/operator | `npm run flowchain:real-value-pilot:control-dashboard`; `npm run flowchain:real-value-pilot:wallet` | Control-dashboard and wallet proofs are merged. | -| Ops path verifies required env, tiny caps, explicit owner ack, emergency stop, export evidence, restart recovery, and no-secret scans. | Ops/installer | `npm run flowchain:real-value-pilot:ops` | Branch command added here; local proof passes, pending PR merge. | +| Ops path verifies required env, tiny caps, explicit owner ack, emergency stop, export evidence, restart recovery, and no-secret scans. | Ops/installer | `npm run flowchain:real-value-pilot:ops` | Merged on `main` by PR #144; latest local main-equivalent proof passed. | | Final pilot gate runs baseline commands plus every available dedicated proof command. | HQ/Ops | `npm run flowchain:real-value-pilot:e2e` | Exists on `main`; strict mode still fails until subsystem commands land. | ## In-Flight Implementation Status @@ -159,12 +162,12 @@ from `main`. | Area | In-flight branch state | Required next step | | --- | --- | --- | -| Contracts | `agent/real-value-pilot-contracts` checklist reports the contracts proof complete, including hardening, deploy dry-run, and product E2E. | Rebase onto `a16fb9a`, expose `flowchain:real-value-pilot:contracts`, rerun evidence, and open a PR. | -| Bridge relayer | `agent/real-value-pilot-bridge` checklist reports the bridge proof complete; service-local `pilot:e2e` exists. | Rebase onto `a16fb9a`, expose `flowchain:real-value-pilot:bridge`, rerun evidence, and open a PR. | -| Chain runtime | `agent/real-value-pilot-chain` checklist reports runtime credit/replay/restart/export proof complete through the direct wrapper; root package command is missing. | Rebase onto `a16fb9a`, expose `flowchain:real-value-pilot:runtime`, rerun evidence, and open a PR. | +| Contracts | `agent/real-value-pilot-contracts` checklist reports the contracts proof complete, including hardening, deploy dry-run, and product E2E. | Rebase onto `6272bf1`, expose `flowchain:real-value-pilot:contracts`, rerun evidence, and open a PR. | +| Bridge relayer | This branch adapts `agent/real-value-pilot-bridge` work onto `6272bf1` and exposes branch-local `flowchain:real-value-pilot:bridge`. | Open a PR for issue #138 so the proof command lands on `main`. | +| Chain runtime | `agent/real-value-pilot-chain` checklist reports runtime credit/replay/restart/export proof complete through the direct wrapper; root package command is missing. | Rebase onto `6272bf1`, expose `flowchain:real-value-pilot:runtime`, rerun evidence, and open a PR. | | Wallet/operator | `flowchain:real-value-pilot:wallet` merged on `main` through PR #143 and closed issue #136. | No wallet/operator blocker remains for the final pilot gate. | | Control plane/dashboard | `flowchain:real-value-pilot:control-dashboard` merged on `main` through PR #142 and closed issue #137. | No control-dashboard blocker remains for the final pilot gate. | -| Ops/installer | This branch adapts `agent/real-value-pilot-ops` work onto `a16fb9a` and exposes branch-local `flowchain:real-value-pilot:ops`. | Open a PR for issue #135 so the proof command lands on `main`. | +| Ops/installer | `flowchain:real-value-pilot:ops` merged on `main` through PR #144 and closed issue #135. | No ops/installer blocker remains for the final pilot gate. | ## Owner Go/No-Go Checklist @@ -192,11 +195,11 @@ in committed files, or if any document presents the pilot as public readiness. ## Current Blockers - Dedicated real-value contracts gate does not exist; tracked by issue #133. -- Dedicated real-value bridge relayer gate does not exist; tracked by issue #138. +- Dedicated real-value bridge relayer gate exists branch-locally and passes; tracked by issue #138 until merged. - Dedicated real-value runtime gate does not exist; tracked by issue #134. - Dedicated real-value wallet/operator gate is merged on `main`; issue #136 is closed by PR #143. - Dedicated real-value control-plane/dashboard gate is merged on `main`; issue #137 is closed by PR #142. -- Dedicated real-value ops/installer gate exists branch-locally and passes; tracked by issue #135 until merged. +- Dedicated real-value ops/installer gate is merged on `main`; issue #135 is closed by PR #144. - Issue #130 is closed by PR #132; the release-gate boundary is now on `main`. - Issue #131 is closed by PR #132; default `contracts:hardening` skips optional Slither unless the audit gate is explicitly requested. @@ -212,7 +215,7 @@ in committed files, or if any document presents the pilot as public readiness. | Chain runtime | #134 | `npm run flowchain:real-value-pilot:runtime` | | Wallet/operator | #136, closed by PR #143 | `npm run flowchain:real-value-pilot:wallet` | | Control plane/dashboard | #137, closed by PR #142 | `npm run flowchain:real-value-pilot:control-dashboard` | -| Ops/installer | #135 | `npm run flowchain:real-value-pilot:ops` | +| Ops/installer | #135, closed by PR #144 | `npm run flowchain:real-value-pilot:ops` | | Release-gate boundary | #130, closed by PR #132 | `npm run flowchain:real-value-pilot:e2e -- -AllowIncomplete` until proofs land | | Static-analysis policy | #131, closed by PR #132 | `npm run contracts:hardening`; `npm run contracts:hardening:slither` | diff --git a/docs/agent-runs/real-value-pilot-bridge/CHECKLIST.md b/docs/agent-runs/real-value-pilot-bridge/CHECKLIST.md new file mode 100644 index 00000000..d0eaac61 --- /dev/null +++ b/docs/agent-runs/real-value-pilot-bridge/CHECKLIST.md @@ -0,0 +1,24 @@ +# Real-Value Pilot Bridge Relayer Checklist + +- [x] Read required repo orientation docs. +- [x] Inspect current `services/bridge-relayer`. +- [x] Inspect `E:\FlowMemory\flowmemory-bridge-full` active bridge-testnet work. +- [x] Inspect `E:\FlowMemory\flowmemory-live-contracts`. +- [x] Inspect current bridge-credit handoff shape. +- [x] Add Base `8453` pilot observer mode. +- [x] Verify `eth_chainId == 0x2105` before Base pilot reads. +- [x] Reject wrong chain IDs. +- [x] Reject unapproved pilot lockbox addresses. +- [x] Support confirmation-depth configuration. +- [x] Write deterministic observation, credit, and pilot evidence files. +- [x] Reject or idempotently record duplicate deposit replay with evidence. +- [x] Apply local FlowChain bridge credit exactly once in pilot/mock E2E state. +- [x] Add withdrawal intent and release evidence path for pilot mode. +- [x] Ensure scripts print exact next operator command after each step. +- [x] Keep committed fixtures, logs, exports, and payloads free of secrets. +- [x] Add `flowchain:real-value-pilot:bridge` bridge proof path. +- [x] Run `npm test --prefix services/bridge-relayer`. +- [x] Run mock pilot E2E without external RPC. +- [x] Run wrong-chain negative tests. +- [x] Run `npm run bridge:local-credit:smoke`. +- [x] Run `npm run flowchain:product-e2e` or assign breakage. diff --git a/docs/agent-runs/real-value-pilot-bridge/EXPERIMENTS.md b/docs/agent-runs/real-value-pilot-bridge/EXPERIMENTS.md new file mode 100644 index 00000000..3aa2c5a8 --- /dev/null +++ b/docs/agent-runs/real-value-pilot-bridge/EXPERIMENTS.md @@ -0,0 +1,32 @@ +# Real-Value Pilot Bridge Relayer Experiments + +## Planned Checks + +| Check | Command | Result | +| --- | --- | --- | +| Bridge unit tests | `npm test --prefix services/bridge-relayer` | passed, 14 tests | +| Mock pilot E2E | `npm run pilot:e2e --prefix services/bridge-relayer` | passed | +| Root pilot bridge command | `npm run flowchain:real-value-pilot:bridge` | passed | +| Local credit smoke | `npm run bridge:local-credit:smoke` | passed | +| Product E2E | `npm run flowchain:product-e2e` | passed after default hardening made Slither optional; explicit Slither audit remains outside bridge scope | +| L1 E2E alias | `npm run flowchain:l1-e2e` | passed | +| HQ pilot gate, report-only | `npm run flowchain:real-value-pilot:e2e -- -AllowIncomplete` | passed as incomplete; only contracts #133 and runtime #134 remained missing | +| HQ pilot gate, strict | `npm run flowchain:real-value-pilot:e2e` | failed clearly as expected; only contracts #133 and runtime #134 remained missing | +| Diff whitespace | `git diff --check` | passed with line-ending warnings only | +| Unsafe-claim scan | `node infra/scripts/check-unsafe-claims.mjs` | passed | +| Live observer script syntax | `[scriptblock]::Create((Get-Content -Raw infra/scripts/bridge-base-mainnet-pilot-observe.ps1))` | passed | + +## Negative Coverage + +- Wrong chain ID must fail before log parsing. +- Base pilot mode must reject unapproved lockbox addresses. +- Base pilot mode must reject insufficient confirmation depth. +- Duplicate replay must produce explicit evidence and no second local + application. +- Artifact secret scan must reject secret-shaped material. + +## Product E2E + +`npm run flowchain:product-e2e` now passes on current `main` after the merged +default/audit hardening split. Explicit Slither audit remains outside the +bridge-relayer scope. diff --git a/docs/agent-runs/real-value-pilot-bridge/NOTES.md b/docs/agent-runs/real-value-pilot-bridge/NOTES.md new file mode 100644 index 00000000..5f4ac1b2 --- /dev/null +++ b/docs/agent-runs/real-value-pilot-bridge/NOTES.md @@ -0,0 +1,39 @@ +# Real-Value Pilot Bridge Relayer Notes + +## Source Context + +- Current integration branch: `agent/real-value-pilot-bridge-proof`. +- GitHub source of truth shows draft PR #129 for the real-value pilot goal pack + and draft PR #113 for the earlier bridge-testnet work. +- This integration branch starts from current `main` after PR #144. +- `E:\FlowMemory\flowmemory-bridge-full` contains useful unmerged bridge E2E + work for duplicate replay and control-plane visibility. It is context only, + not source of truth. +- `E:\FlowMemory\flowmemory-live-contracts` exists and is clean at `origin/main` + at inspection time. Its current `BaseBridgeLockbox` event shape matches the + relayer parser. + +## Runtime Handoff Shape + +The current handoff is `flowmemory.bridge_runtime_handoff.v0` in +`fixtures/bridge/local-runtime-bridge-handoff.json`. It carries observations, +credits, withdrawal intents, replay keys, duplicate replay keys, and +workbench-ready timeline/record projections. The control plane currently reads +bridge observations and projects deposits/credits; it does not yet consume a +stateful runtime application ledger. + +## Design Choice + +The Base public-network pilot should be a distinct mode from the existing +read-only `base-mainnet-canary` mode. This keeps the historical canary guardrail +intact while allowing the explicit pilot to require approved contracts, +confirmation depth, capped operator acknowledgement, deterministic evidence, and +exactly-once local application state. + +## Package Alias Note + +The root `package.json` was updated with +`flowchain:real-value-pilot:bridge` because the merged +`flowchain:real-value-pilot:e2e` is the HQ final gate. All substantive +implementation remains in the assigned bridge, schema, fixture, script, and +docs surfaces. diff --git a/docs/agent-runs/real-value-pilot-bridge/PLAN.md b/docs/agent-runs/real-value-pilot-bridge/PLAN.md new file mode 100644 index 00000000..561017bc --- /dev/null +++ b/docs/agent-runs/real-value-pilot-bridge/PLAN.md @@ -0,0 +1,49 @@ +# Real-Value Pilot Bridge Relayer Plan + +Status: implemented on branch `agent/real-value-pilot-bridge-proof`; pending +PR for issue #138. + +## Scope + +Implement the bridge relayer path for a tiny capped Base public-network pilot on +chain ID `8453`. The relayer must observe only an explicit approved lockbox, +derive deterministic bridge observation, credit, and evidence objects, hand the +credit to local FlowChain exactly once, and emit pilot withdrawal/release +evidence without broadcasting a release. + +## Allowed Edit Areas + +- `services/bridge-relayer/` +- `fixtures/bridge/` +- `schemas/flowmemory/bridge*.json` +- `infra/scripts/bridge-*.ps1` +- `infra/scripts/flowchain-real-value*.ps1` +- `docs/bridge/` +- `docs/agent-runs/real-value-pilot-bridge/` + +## Implementation Steps + +1. Preserve the existing mock, local Anvil, Base Sepolia, and read-only Base + canary paths. +2. Add a distinct `base-mainnet-pilot` mode for explicit, capped Base `8453` + observation. +3. Require approved lockbox addresses for the pilot observer and reject + unapproved contracts before reading logs. +4. Add confirmation-depth support using `eth_blockNumber` before `eth_getLogs`. +5. Generate deterministic observation, credit, runtime handoff, pilot evidence, + and release-evidence artifacts. +6. Add exactly-once local credit application state for pilot/mock E2E replay + checks. +7. Add a no-RPC mock pilot E2E and a PowerShell wrapper that prints exact next + operator commands after every step. +8. Update bridge docs with mock and live pilot commands, env vars, replay + behavior, and failure/retry behavior. +9. Run the requested bridge tests, pilot mock E2E, wrong-chain tests, local + credit smoke, and product E2E. + +## Boundary + +This is not a production bridge, public deposit launch, audited security claim, +or production release authority path. Live Base `8453` mode is read-only until +the relayer derives local artifacts; it does not sign or broadcast release +transactions. diff --git a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md index 69961d13..2b63b650 100644 --- a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md +++ b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md @@ -81,6 +81,7 @@ npm install npm run bridge:mock npm run bridge:test npm run bridge:local-credit:smoke +npm run flowchain:real-value-pilot:bridge ``` Expected output: @@ -92,6 +93,23 @@ services/bridge-relayer/out/bridge-runtime-handoff.json fixtures/bridge/local-runtime-bridge-handoff.json ``` +The real-value pilot mock E2E uses Base chain ID `8453` fixture data without +external RPC and writes: + +```text +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-release-evidence.json +services/bridge-relayer/out/real-value-pilot-e2e/bridge-runtime-handoff.json +services/bridge-relayer/out/real-value-pilot-e2e/bridge-replay-handoff.json +services/bridge-relayer/out/real-value-pilot-e2e/bridge-credit-application-state.json +``` + +It proves deterministic IDs, wrong-chain rejection, unapproved-lockbox rejection, +duplicate replay evidence, exactly-once local credit application, and +test-record-only withdrawal/release evidence. + ## Base Sepolia Smoke Deploy the lockbox with Foundry or a deployment script, then run: @@ -251,6 +269,82 @@ 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. +## Base 8453 Pilot Observer + +The pilot observer is distinct from the read-only canary path. It is for a tiny +capped owner-operated pilot only and still does not broadcast releases. + +Required environment variables: + +```text +FLOWCHAIN_BASE8453_RPC_URL +FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS +FLOWCHAIN_BASE8453_APPROVED_LOCKBOX_ADDRESS +FLOWCHAIN_BASE8453_FROM_BLOCK +FLOWCHAIN_BASE8453_TO_BLOCK +FLOWCHAIN_BASE8453_CONFIRMATIONS +FLOWCHAIN_PILOT_MAX_USD +FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI +FLOWCHAIN_PILOT_TOTAL_CAP_WEI +FLOWCHAIN_PILOT_OPERATOR_ACK=I_UNDERSTAND_THIS_IS_A_TINY_CAPPED_BASE8453_PILOT +``` + +Mock mode, no external RPC: + +```powershell +npm run flowchain:real-value-pilot:bridge +``` + +Live observer mode: + +```powershell +npm run bridge:base8453:pilot:observe -- -OperatorAck -ApplyCredit -WithdrawalIntent +``` + +Equivalent direct command: + +```powershell +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_APPROVED_LOCKBOX_ADDRESS ` + --from-block $env:FLOWCHAIN_BASE8453_FROM_BLOCK ` + --to-block $env:FLOWCHAIN_BASE8453_TO_BLOCK ` + --confirmations $env:FLOWCHAIN_BASE8453_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 ` + --apply-credit ` + --withdrawal-intent ` + --runtime-state services/bridge-relayer/out/base8453-pilot-credit-application-state.json ` + --out services/bridge-relayer/out/base8453-pilot-bridge-observation.json ` + --credit-out services/bridge-relayer/out/base8453-pilot-bridge-credit.json ` + --handoff-out services/bridge-relayer/out/base8453-pilot-bridge-handoff.json ` + --evidence-out services/bridge-relayer/out/base8453-pilot-evidence.json ` + --withdrawal-out services/bridge-relayer/out/base8453-pilot-withdrawal-intent.json ` + --release-evidence-out services/bridge-relayer/out/base8453-pilot-release-evidence.json +``` + +Failure, retry, and replay behavior: + +- Wrong `eth_chainId` fails before `eth_getLogs`; Base must return `0x2105`. +- Unapproved lockbox addresses fail before log reads. +- If `toBlock` is newer than `latestBlock - confirmations`, the observer fails + with an insufficient-confirmations error; retry after more blocks or lower the + explicitly configured confirmation depth. +- Duplicate logs in one batch produce one applied credit and one rejected credit + with `duplicate_replay_key` evidence. +- Re-running the same deposit with the same runtime application state is + idempotent: the credit is rejected with `already_applied_replay_key` and no + second local application is recorded. +- Withdrawal/release evidence is written as a local operator record only. The + relayer does not sign or broadcast `releaseERC20` or `releaseNative`. +- RPC URLs, keys, seed phrases, mnemonics, API keys, and webhooks must stay in + local environment/config only and are not written to artifacts. + ## Commands ```powershell @@ -260,6 +354,7 @@ npm run bridge:test npm run bridge:mock npm run bridge:sepolia:observe npm run bridge:local-credit:smoke +npm run flowchain:real-value-pilot:bridge npm run flowchain:full-smoke git diff --check ``` 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..77cf494c --- /dev/null +++ b/infra/scripts/bridge-base-mainnet-pilot-observe.ps1 @@ -0,0 +1,136 @@ +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]$FromBlock = $env:FLOWCHAIN_BASE8453_FROM_BLOCK, + + [string]$ToBlock = $env:FLOWCHAIN_BASE8453_TO_BLOCK, + + [string]$Confirmations = $(if ($env:FLOWCHAIN_BASE8453_CONFIRMATIONS) { $env:FLOWCHAIN_BASE8453_CONFIRMATIONS } else { "2" }), + + [string]$MaxUsd = $env:FLOWCHAIN_PILOT_MAX_USD, + + [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 +} + +$ackFromEnv = $env:FLOWCHAIN_PILOT_OPERATOR_ACK -eq "I_UNDERSTAND_THIS_IS_A_TINY_CAPPED_BASE8453_PILOT" +$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($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_BASE8453_CONFIRMATIONS or -Confirmations" } +if ([string]::IsNullOrWhiteSpace($MaxUsd)) { $missing += "FLOWCHAIN_PILOT_MAX_USD or -MaxUsd" } +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=I_UNDERSTAND_THIS_IS_A_TINY_CAPPED_BASE8453_PILOT 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 "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_APPROVED_LOCKBOX_ADDRESS --from-block `$env:FLOWCHAIN_BASE8453_FROM_BLOCK --to-block `$env:FLOWCHAIN_BASE8453_TO_BLOCK --confirmations `$env:FLOWCHAIN_BASE8453_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, + "--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/package.json b/package.json index d79d409c..b8ea1690 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "flowchain:real-value-pilot:ops": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-real-value-pilot-ops-e2e.ps1", "flowchain:real-value-pilot:emergency-stop": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-real-value-pilot-emergency-stop.ps1", "flowchain:real-value-pilot:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-real-value-pilot-export.ps1", + "flowchain:real-value-pilot:bridge": "npm run pilot:e2e --prefix services/bridge-relayer", "flowchain:real-value-pilot:control-dashboard": "npm run real-value-pilot:e2e --prefix services/control-plane", "flowchain:real-value-pilot:wallet": "npm run wallet:pilot-e2e --prefix crypto", "flowchain:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-export.ps1", diff --git a/schemas/flowmemory/bridge-observation-set.schema.json b/schemas/flowmemory/bridge-observation-set.schema.json index b85fb578..fde63071 100644 --- a/schemas/flowmemory/bridge-observation-set.schema.json +++ b/schemas/flowmemory/bridge-observation-set.schema.json @@ -17,7 +17,7 @@ "schema": { "const": "flowmemory.bridge_observation_set.v0" }, "observationSetId": { "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 }, "count": { "type": "integer", "minimum": 0 }, "observations": { diff --git a/schemas/flowmemory/bridge-observation.schema.json b/schemas/flowmemory/bridge-observation.schema.json index 0297b6cb..431fb0be 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,22 @@ "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]+$" }, + "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..f4779f23 --- /dev/null +++ b/schemas/flowmemory/bridge-pilot-evidence.schema.json @@ -0,0 +1,107 @@ +{ + "$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", "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" }, + "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/services/bridge-relayer/README.md b/services/bridge-relayer/README.md index cf9d352f..e729960e 100644 --- a/services/bridge-relayer/README.md +++ b/services/bridge-relayer/README.md @@ -12,6 +12,39 @@ Local mock: npm run bridge:mock ``` +Mock real-value pilot E2E, with no external RPC: + +```powershell +npm run flowchain:real-value-pilot:bridge +``` + +The mock pilot E2E writes deterministic observation, credit, pilot evidence, +withdrawal intent, release evidence, runtime handoff, replay handoff, and +exactly-once application-state files under +`services/bridge-relayer/out/real-value-pilot-e2e/`. + +Base public-network pilot observation: + +```powershell +$env:FLOWCHAIN_BASE8453_RPC_URL="" +$env:FLOWCHAIN_BASE8453_LOCKBOX_ADDRESS="" +$env:FLOWCHAIN_BASE8453_APPROVED_LOCKBOX_ADDRESS="" +$env:FLOWCHAIN_BASE8453_FROM_BLOCK="" +$env:FLOWCHAIN_BASE8453_TO_BLOCK="" +$env:FLOWCHAIN_BASE8453_CONFIRMATIONS="2" +$env:FLOWCHAIN_PILOT_MAX_USD="1" +$env:FLOWCHAIN_PILOT_MAX_DEPOSIT_WEI="" +$env:FLOWCHAIN_PILOT_TOTAL_CAP_WEI="" +$env:FLOWCHAIN_PILOT_OPERATOR_ACK="I_UNDERSTAND_THIS_IS_A_TINY_CAPPED_BASE8453_PILOT" +npm run bridge:base8453:pilot:observe -- -OperatorAck -ApplyCredit -WithdrawalIntent +``` + +The observer verifies `eth_chainId == 0x2105`, rejects unapproved lockboxes, +enforces confirmation depth before `eth_getLogs`, and never prints or writes the +RPC URL. Re-running the same observed event is idempotent: the existing local +application is cited and the replay credit is rejected with evidence instead of +applying a second local credit. + The control plane can read `services/bridge-relayer/out/bridge-observation.json` and can intake additional local bridge-agent observations through JSON-RPC `bridge_observation_submit` or HTTP `POST /bridge/observations`. Readbacks are @@ -30,6 +63,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-se Base mainnet canary reads are disabled unless the operator explicitly passes the real-funds acknowledgement and keeps the requested cap at or below 25 USD. +The separate Base `8453` pilot mode also requires an approved lockbox list, +operator acknowledgement, configured confirmation depth, and tiny amount caps. No private key, seed phrase, RPC credential, or API key belongs in this package or in committed fixtures. diff --git a/services/bridge-relayer/package.json b/services/bridge-relayer/package.json index d6580122..854bed7d 100644 --- a/services/bridge-relayer/package.json +++ b/services/bridge-relayer/package.json @@ -4,6 +4,7 @@ "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", "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/bridge-pilot-e2e.ts b/services/bridge-relayer/src/bridge-pilot-e2e.ts new file mode 100644 index 00000000..502918ac --- /dev/null +++ b/services/bridge-relayer/src/bridge-pilot-e2e.ts @@ -0,0 +1,404 @@ +import assert from "node:assert/strict"; +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 BridgeMode, + type BridgePipelineResult, +} 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; +} + +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 WRONG_APPROVED_LOCKBOX = "0x9999999999999999999999999999999999999999"; + +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"; + + 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 { + 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, + }; +} + +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, + "--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; +} + +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 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 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(handoffPath, firstRun.handoff); + writeJson(replayHandoffPath, duplicateRun.handoff); + + [ + observation, + credit, + evidence, + releaseEvidence, + withdrawal, + 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), + 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, + }, + 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_FROM_BLOCK", + "FLOWCHAIN_BASE8453_TO_BLOCK", + "FLOWCHAIN_BASE8453_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..70e6e2b8 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,10 +15,13 @@ 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 DEFAULT_PILOT_CONFIRMATIONS = 2; export const BRIDGE_DEPOSIT_EVENT_SIGNATURE_TEXT = "BridgeDeposit(bytes32,uint256,address,address,uint256,bytes32,uint256,bytes32)"; export const BRIDGE_DEPOSIT_TOPIC0 = keccak256Utf8(BRIDGE_DEPOSIT_EVENT_SIGNATURE_TEXT); @@ -30,7 +34,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 +83,10 @@ export interface BridgeObservation { explicitBlockRange: boolean; noSecrets: boolean; maxUsd?: number; + maxDepositAmount?: string; + totalCapAmount?: string; + confirmation?: BridgeConfirmationEvidence; + approvedContract?: boolean; }; } @@ -138,6 +160,87 @@ 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; + 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 +251,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 +297,28 @@ interface CliOptions { fromBlock?: string; toBlock?: string; expectedChainId?: BridgeSourceChainId; + approvedLockboxAddresses: `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 +370,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 +405,26 @@ 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 expectedChainIdForMode(mode: BridgeMode, explicit?: BridgeSourceChainId): BridgeSourceChainId { if (explicit !== undefined) { return explicit; @@ -290,6 +435,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 +453,19 @@ export function parseBridgeArgs(args: string[]): CliOptions { let fromBlock: string | undefined; let toBlock: string | undefined; let expectedChainId: BridgeSourceChainId | undefined; + let approvedLockboxAddresses: `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 +473,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 +513,31 @@ 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 === "--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 +545,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 +584,40 @@ 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"); + } } + const resolvedConfirmationDepth = confirmationDepth ?? (isPilotMode(mode) ? DEFAULT_PILOT_CONFIRMATIONS : 0); + return { mode, fixturePath, @@ -411,11 +630,19 @@ export function parseBridgeArgs(args: string[]): CliOptions { fromBlock, toBlock, expectedChainId, + approvedLockboxAddresses: [...new Set(approvedLockboxAddresses)].sort() as `0x${string}`[], + confirmationDepth: resolvedConfirmationDepth, acknowledgeRealFunds, + acknowledgePilot, maxUsd, + maxDepositAmount, + totalCapAmount, applyCredit, withdrawalIntent, withdrawalBaseRecipient, + runtimeStatePath, + evidenceOutPath, + releaseEvidenceOutPath, }; } @@ -478,11 +705,27 @@ export function bridgeReplayKey(deposit: BridgeDeposit): `0x${string}` { }); } +interface BridgeGuardrailOptions { + maxUsd?: number; + maxDepositAmount?: string; + totalCapAmount?: 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 +742,13 @@ 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.confirmation === undefined ? {} : { confirmation: guardrails.confirmation }), + ...(guardrails.approvedContract === undefined ? {} : { approvedContract: guardrails.approvedContract }), }, }; } @@ -579,6 +826,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 +892,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 +1133,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 +1144,9 @@ export function makeRuntimeHandoff( observations, credits, withdrawalIntents, + runtimeApplications, + pilotEvidence, + releaseEvidences, replayProtection: { strategy: "source-chain-contract-tx-log-deposit", replayKeys, @@ -811,10 +1201,176 @@ 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, + 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 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})`); + } + assertApprovedLockbox(deposit.sourceContract, 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"); @@ -874,11 +1430,43 @@ async function readChainId(rpcUrl: string): Promise { return Number(BigInt(result)); } -async function readBridgeDepositLogs(options: CliOptions): Promise { +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", [{ @@ -888,18 +1476,41 @@ async function readBridgeDepositLogs(options: CliOptions): Promise !log.removed) .map((log) => parseBridgeDepositLog(log, expectedChainId)); + for (const deposit of deposits) { + assertApprovedLockbox(deposit.sourceContract, 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, + 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 +1522,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 +1574,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(`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 +1607,10 @@ 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(`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 +1631,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..572c9c8c 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, @@ -21,6 +24,8 @@ import { } from "../src/observe-base-lockbox.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 +39,12 @@ 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-pilot-evidence.schema.json", + "bridge-release-evidence.schema.json", "bridge-runtime-handoff.schema.json", ].forEach((name) => ajv.addSchema(readSchema(name), name)); return ajv; @@ -63,18 +72,18 @@ 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"; return { - address: "0x1111111111111111111111111111111111111111", + address, topics: [ BRIDGE_DEPOSIT_TOPIC0, "0x7777777777777777777777777777777777777777777777777777777777777777", - topic(BigInt(BASE_SEPOLIA_CHAIN_ID)), + topic(BigInt(chainId)), addressTopic(sender), ], data: `0x${[ @@ -149,6 +158,123 @@ 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", + "--acknowledge-pilot", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--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("mock pilot duplicate deposits reject replay with explicit evidence", async () => { + const result = await runBridgePipeline(parseBridgeArgs([ + "--mode", + "mock-pilot", + "--fixture", + fileURLToPath(pilotDuplicateFixtureUrl), + "--approved-lockbox", + "0x1111111111111111111111111111111111111111", + "--acknowledge-pilot", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "40000000", + "--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", + "--acknowledge-pilot", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--apply-credit", + ])), + /pilot deposit must be from Base chain 8453/, + ); + + await assert.rejects( + () => runBridgePipeline(parseBridgeArgs([ + "--mode", + "mock-pilot", + "--fixture", + fileURLToPath(pilotFixtureUrl), + "--approved-lockbox", + "0x9999999999999999999999999999999999999999", + "--acknowledge-pilot", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + "--apply-credit", + ])), + /unapproved bridge lockbox address/, + ); }); test("decodes BaseBridgeLockbox BridgeDeposit logs from RPC log payloads", () => { @@ -210,6 +336,196 @@ 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", + "--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", + "--from-block", + "100", + "--to-block", + "100", + "--acknowledge-pilot", + "--acknowledge-real-funds", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + ])), + /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", + "--from-block", + "100", + "--to-block", + "100", + "--acknowledge-pilot", + "--acknowledge-real-funds", + "--max-usd", + "1", + "--max-deposit-amount", + "20000000", + "--total-cap-amount", + "20000000", + ])), + /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", + ])), + /insufficient confirmations/, + ); + assert.deepEqual(calls, ["eth_chainId", "eth_blockNumber"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("requires explicit Base mainnet real-funds guardrails", () => { assert.throws( () => parseBridgeArgs([