diff --git a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md index 1d86f6f2..30bbd8a9 100644 --- a/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md +++ b/docs/bridge/FLOWCHAIN_BASE_BRIDGE_POC.md @@ -14,12 +14,24 @@ public bridge, and not approved for broad mainnet use. - `tests/bridge/BaseBridgeLockbox.t.sol`: Foundry coverage for token allowlisting, ERC-20 deposits, native deposits, caps, pause behavior, ownership, release, and replay protection. -- `services/bridge-relayer/`: fixture-first observer that converts explicit - bridge deposit records into FlowChain bridge observation JSON. +- `services/bridge-relayer/`: fixture-first and RPC-range observer that + converts explicit bridge deposit records into FlowChain bridge observation, + credit, withdrawal-intent, and runtime handoff JSON. - `fixtures/bridge/base-sepolia-mock-deposit.json`: deterministic test deposit. +- `fixtures/bridge/local-runtime-bridge-handoff.json`: deterministic local + bridge handoff consumed by the runtime/control-plane agent until a direct + intake endpoint is merged. - `schemas/flowmemory/bridge-deposit.schema.json` and `schemas/flowmemory/bridge-observation.schema.json`: bridge object contracts. +- `schemas/flowmemory/bridge-credit.schema.json`, + `schemas/flowmemory/bridge-withdrawal-intent.schema.json`, and + `schemas/flowmemory/bridge-runtime-handoff.schema.json`: canonical local + credit, test withdrawal-intent, and handoff contracts. +- `infra/scripts/bridge-base-sepolia-observe.ps1`: env-friendly Base Sepolia + observation wrapper that requires no private key. - `infra/scripts/bridge-base-sepolia-smoke.ps1`: guarded Base Sepolia smoke. +- `infra/scripts/bridge-local-anvil-observe.ps1`: local Anvil observation + wrapper for chain id `31337`. - `infra/scripts/bridge-base-mainnet-canary-read.ps1`: disabled-by-default Base mainnet canary read wrapper. @@ -30,14 +42,25 @@ Base Sepolia user/test wallet -> BaseBridgeLockbox.lockERC20 or lockNative -> BridgeDeposit event -> bridge-relayer explicit reader/mock observer - -> FlowChain bridge deposit observation fixture - -> local control plane / workbench / devnet handoff + -> BridgeObservation with replay key + -> BridgeCredit pending/applied local object + -> local runtime/control-plane/workbench handoff ``` The POC does not mint production assets on FlowChain. Local acceptance is a fixture/control-plane event until the private/local runtime explicitly consumes bridge deposit objects. +The handoff includes a workbench-ready timeline: + +```text +deposit observed -> credit pending -> credit applied -> withdrawal requested +``` + +The current workbench/control-plane packages are outside this bridge-agent +scope. Until their bridge intake lands, `fixtures/bridge/local-runtime-bridge-handoff.json` +is the exact file for the runtime/control-plane agent to consume. + ## Risk Model - Base mainnet uses real funds. Mainnet canary reads require @@ -56,12 +79,16 @@ bridge deposit objects. npm install npm run bridge:mock npm run bridge:test +npm run bridge:local-credit:smoke ``` Expected output: ```text services/bridge-relayer/out/bridge-observation.json +services/bridge-relayer/out/bridge-credit.json +services/bridge-relayer/out/bridge-runtime-handoff.json +fixtures/bridge/local-runtime-bridge-handoff.json ``` ## Base Sepolia Smoke @@ -79,6 +106,36 @@ powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-se The script checks Base Sepolia chain id `84532`, requires an explicit lockbox, requires an explicit block range, and writes a local observation output. +The root package also exposes an env-var smoke path that does not require a +private key: + +```powershell +$env:BASE_SEPOLIA_RPC_URL="" +$env:BASE_BRIDGE_LOCKBOX_ADDRESS="" +$env:BASE_BRIDGE_FROM_BLOCK="" +$env:BASE_BRIDGE_TO_BLOCK="" +npm run bridge:sepolia:observe +``` + +This command reads only `BridgeDeposit` logs from the explicit lockbox and +range, then writes observation, credit, and handoff JSON under +`services/bridge-relayer/out/`. + +## Local Anvil Observation + +Local Anvil is supported as a mock Base event lane with chain id `31337`. +Deploy `BaseBridgeLockbox`, emit one or more deposits, then run: + +```powershell +$env:ANVIL_BRIDGE_LOCKBOX_ADDRESS="" +$env:ANVIL_BRIDGE_FROM_BLOCK="" +$env:ANVIL_BRIDGE_TO_BLOCK="" +npm run bridge:anvil:observe +``` + +Use `-RpcUrl` or `ANVIL_RPC_URL` if the Anvil endpoint is not +`http://127.0.0.1:8545`. + ## Base Mainnet Canary Read Only after review, and only for a tiny capped canary: @@ -94,7 +151,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-ma ``` The script checks Base mainnet chain id `8453` and refuses a canary above -`25` USD. +`25` USD. It is read-only and prints the chain, lockbox, block range, max USD +guardrail, and broadcast status before it reads logs. ## Commands @@ -102,6 +160,9 @@ The script checks Base mainnet chain id `8453` and refuses a canary above forge test --match-path tests/bridge/BaseBridgeLockbox.t.sol npm run bridge:test npm run bridge:mock +npm run bridge:sepolia:observe +npm run bridge:local-credit:smoke +npm run flowchain:full-smoke git diff --check ``` diff --git a/fixtures/bridge/local-runtime-bridge-handoff.json b/fixtures/bridge/local-runtime-bridge-handoff.json new file mode 100644 index 00000000..29d56b9d --- /dev/null +++ b/fixtures/bridge/local-runtime-bridge-handoff.json @@ -0,0 +1,213 @@ +{ + "schema": "flowmemory.bridge_runtime_handoff.v0", + "handoffId": "0xb8f818f1c45a864a7134b298b993952edda161824a4120c7716ce950fe63a2ca", + "generatedAt": "2026-05-13T00:00:00.000Z", + "mode": "mock", + "productionReady": false, + "localOnly": true, + "observations": [ + { + "schema": "flowmemory.bridge_deposit_observation.v0", + "observationId": "0x0430f0f7818add19ccd9037dcf6e50d75c1fb0fac0441f9b042c473d1d2d223c", + "replayKey": "0x9c97eb0fa65cb3eec9274cb0c9e925351608e7abe6980fe2525820048bd81e09", + "observedAt": "2026-05-13T00:00:00.000Z", + "mode": "mock", + "productionReady": false, + "deposit": { + "schema": "flowmemory.bridge_deposit.v0", + "depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269", + "sourceChainId": 84532, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "logIndex": 0, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "1", + "metadataHash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "status": "observed" + }, + "guardrails": { + "explicitChainId": true, + "explicitContract": true, + "explicitBlockRange": false, + "noSecrets": true + } + } + ], + "credits": [ + { + "schema": "flowmemory.bridge_credit.v0", + "creditId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "observationId": "0x0430f0f7818add19ccd9037dcf6e50d75c1fb0fac0441f9b042c473d1d2d223c", + "depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269", + "replayKey": "0x9c97eb0fa65cb3eec9274cb0c9e925351608e7abe6980fe2525820048bd81e09", + "source": { + "chainId": 84532, + "contract": "0x1111111111111111111111111111111111111111", + "txHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "logIndex": 0 + }, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "status": "applied", + "appliedAt": "2026-05-13T00:00:00.000Z", + "localOnly": true, + "productionReady": false + } + ], + "withdrawalIntents": [ + { + "schema": "flowmemory.bridge_withdrawal_intent.v0", + "withdrawalIntentId": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751", + "creditId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "depositId": "0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269", + "sourceChainId": 84532, + "destinationChainId": 84532, + "token": "0x3333333333333333333333333333333333333333", + "amount": "20000000", + "flowchainAccount": "0x5555555555555555555555555555555555555555555555555555555555555555", + "baseRecipient": "0x4444444444444444444444444444444444444444", + "status": "requested", + "requestedAt": "2026-05-13T00:00:00.000Z", + "testMode": true, + "broadcast": false, + "releasePolicy": "test_record_only", + "productionReady": false + } + ], + "replayProtection": { + "strategy": "source-chain-contract-tx-log-deposit", + "replayKeys": [ + "0x9c97eb0fa65cb3eec9274cb0c9e925351608e7abe6980fe2525820048bd81e09" + ], + "duplicateReplayKeys": [] + }, + "runtimeIntake": { + "status": "handoff_file", + "consumer": "flowchain-runtime-agent", + "expectedPath": "fixtures/bridge/local-runtime-bridge-handoff.json", + "note": "Runtime/control-plane bridge intake is not merged in this scope. Consume this file as the deterministic bridge credit handoff." + }, + "workbenchTimeline": [ + { + "phase": "deposit_observed", + "status": "observed", + "objectId": "0x0430f0f7818add19ccd9037dcf6e50d75c1fb0fac0441f9b042c473d1d2d223c", + "title": "Deposit observed", + "summary": "Observed lockbox deposit 0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269 on chain 84532." + }, + { + "phase": "credit_pending", + "status": "pending", + "objectId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "title": "Credit pending", + "summary": "20000000 test units queued for 0x5555555555555555555555555555555555555555555555555555555555555555." + }, + { + "phase": "credit_applied", + "status": "applied", + "objectId": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "title": "Credit applied", + "summary": "20000000 test units applied in local bridge smoke state." + }, + { + "phase": "withdrawal_requested", + "status": "requested", + "objectId": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751", + "title": "Withdrawal requested", + "summary": "Test-mode local-to-Base withdrawal intent recorded with no broadcast or real release." + } + ], + "workbenchRecords": [ + { + "sectionKey": "transactions", + "id": "0x0430f0f7818add19ccd9037dcf6e50d75c1fb0fac0441f9b042c473d1d2d223c", + "kind": "Bridge deposit observation", + "title": "0x2222222222222222222222222222222222222222222222222222222222222222", + "summary": "Deposit 0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269 observed from mock.", + "status": "observed", + "facts": [ + { + "label": "chain", + "value": "84532" + }, + { + "label": "lockbox", + "value": "0x1111111111111111111111111111111111111111" + }, + { + "label": "log index", + "value": "0" + }, + { + "label": "amount", + "value": "20000000" + } + ], + "rawRef": "0x0430f0f7818add19ccd9037dcf6e50d75c1fb0fac0441f9b042c473d1d2d223c" + }, + { + "sectionKey": "receipts", + "id": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "kind": "Bridge credit", + "title": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6", + "summary": "Credit applied for deposit 0x7e3a7f7ab7dc9b07d762c1f2fce315cf0c08f1a7e854b4dbcb2359efcb9cb269.", + "status": "verified", + "facts": [ + { + "label": "recipient", + "value": "0x5555555555555555555555555555555555555555555555555555555555555555" + }, + { + "label": "amount", + "value": "20000000" + }, + { + "label": "token", + "value": "0x3333333333333333333333333333333333333333" + }, + { + "label": "replay key", + "value": "0x9c97eb0fa65cb3eec9274cb0c9e925351608e7abe6980fe2525820048bd81e09" + } + ], + "rawRef": "0xff3efb8221533cfc836bffbcee10bdd2d7d4a5615efce9516574245a3b7d74a6" + }, + { + "sectionKey": "transactions", + "id": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751", + "kind": "Bridge withdrawal intent", + "title": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751", + "summary": "Test-mode withdrawal intent recorded; no mainnet or real-funds release is broadcast.", + "status": "pending", + "facts": [ + { + "label": "base recipient", + "value": "0x4444444444444444444444444444444444444444" + }, + { + "label": "amount", + "value": "20000000" + }, + { + "label": "broadcast", + "value": "false" + }, + { + "label": "policy", + "value": "test_record_only" + } + ], + "rawRef": "0xe6f0da66dc9659e427640f119b24a83b01ccb2f79c745d6d4c28570c5e5e1751" + } + ], + "limitations": [ + "Bridge objects are for mock, local Anvil, and Base Sepolia test validation by default.", + "No production bridge readiness, audited security, or trustless finality is claimed.", + "Withdrawal intents are test-mode records only and do not broadcast releases.", + "RPC URLs and private keys are never written to bridge artifacts." + ] +} diff --git a/infra/scripts/bridge-base-mainnet-canary-read.ps1 b/infra/scripts/bridge-base-mainnet-canary-read.ps1 index 0f996613..b4af8a88 100644 --- a/infra/scripts/bridge-base-mainnet-canary-read.ps1 +++ b/infra/scripts/bridge-base-mainnet-canary-read.ps1 @@ -18,7 +18,11 @@ param( [ValidateRange(0.01, 25)] [double]$MaxUsd, - [string]$Out = "services/bridge-relayer/out/base-mainnet-canary-bridge-observation.json" + [string]$Out = "services/bridge-relayer/out/base-mainnet-canary-bridge-observation.json", + + [string]$CreditOut = "services/bridge-relayer/out/base-mainnet-canary-bridge-credit.json", + + [string]$HandoffOut = "services/bridge-relayer/out/base-mainnet-canary-bridge-handoff.json" ) $ErrorActionPreference = "Stop" @@ -26,6 +30,13 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) Set-Location -LiteralPath $repoRoot +Write-Host "Reading Base mainnet bridge canary logs." -ForegroundColor Yellow +Write-Host "Chain: Base mainnet (8453)" +Write-Host "Lockbox: $LockboxAddress" +Write-Host "Block range: $FromBlock-$ToBlock" +Write-Host "Max USD guardrail: $MaxUsd" +Write-Host "Broadcast: false; this command is read-only." + npm run bridge:observe -- ` --mode base-mainnet-canary ` --rpc-url $RpcUrl ` @@ -34,6 +45,8 @@ npm run bridge:observe -- ` --to-block $ToBlock ` --acknowledge-real-funds ` --max-usd $MaxUsd ` - --out $Out + --out $Out ` + --credit-out $CreditOut ` + --handoff-out $HandoffOut -Write-Host "Base mainnet canary bridge read wrote $Out" -ForegroundColor Green +Write-Host "Base mainnet canary bridge read wrote $Out and $HandoffOut" -ForegroundColor Green diff --git a/infra/scripts/bridge-base-sepolia-observe.ps1 b/infra/scripts/bridge-base-sepolia-observe.ps1 new file mode 100644 index 00000000..0e203cd2 --- /dev/null +++ b/infra/scripts/bridge-base-sepolia-observe.ps1 @@ -0,0 +1,48 @@ +param( + [string]$RpcUrl = $env:BASE_SEPOLIA_RPC_URL, + + [string]$LockboxAddress = $env:BASE_BRIDGE_LOCKBOX_ADDRESS, + + [string]$FromBlock = $env:BASE_BRIDGE_FROM_BLOCK, + + [string]$ToBlock = $env:BASE_BRIDGE_TO_BLOCK, + + [string]$Out = "services/bridge-relayer/out/base-sepolia-bridge-observation.json", + + [string]$CreditOut = "services/bridge-relayer/out/base-sepolia-bridge-credit.json", + + [string]$HandoffOut = "services/bridge-relayer/out/base-sepolia-bridge-handoff.json" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location -LiteralPath $repoRoot + +$missing = @() +if ([string]::IsNullOrWhiteSpace($RpcUrl)) { $missing += "BASE_SEPOLIA_RPC_URL or -RpcUrl" } +if ([string]::IsNullOrWhiteSpace($LockboxAddress)) { $missing += "BASE_BRIDGE_LOCKBOX_ADDRESS or -LockboxAddress" } +if ([string]::IsNullOrWhiteSpace($FromBlock)) { $missing += "BASE_BRIDGE_FROM_BLOCK or -FromBlock" } +if ([string]::IsNullOrWhiteSpace($ToBlock)) { $missing += "BASE_BRIDGE_TO_BLOCK or -ToBlock" } + +if ($missing.Count -gt 0) { + throw "Base Sepolia bridge observation needs: $($missing -join ', '). No private key is required." +} + +Write-Host "Observing Base Sepolia bridge deposits." -ForegroundColor Cyan +Write-Host "Chain: Base Sepolia (84532)" +Write-Host "Lockbox: $LockboxAddress" +Write-Host "Block range: $FromBlock-$ToBlock" +Write-Host "Broadcast: false; private key not required." + +npm run bridge:observe -- ` + --mode base-sepolia ` + --rpc-url $RpcUrl ` + --lockbox-address $LockboxAddress ` + --from-block $FromBlock ` + --to-block $ToBlock ` + --out $Out ` + --credit-out $CreditOut ` + --handoff-out $HandoffOut + +Write-Host "Base Sepolia bridge observation wrote $Out and $HandoffOut" -ForegroundColor Green diff --git a/infra/scripts/bridge-base-sepolia-smoke.ps1 b/infra/scripts/bridge-base-sepolia-smoke.ps1 index b3f09b13..f25f1dd0 100644 --- a/infra/scripts/bridge-base-sepolia-smoke.ps1 +++ b/infra/scripts/bridge-base-sepolia-smoke.ps1 @@ -11,7 +11,11 @@ param( [Parameter(Mandatory = $true)] [string]$ToBlock, - [string]$Out = "services/bridge-relayer/out/base-sepolia-bridge-observation.json" + [string]$Out = "services/bridge-relayer/out/base-sepolia-bridge-observation.json", + + [string]$CreditOut = "services/bridge-relayer/out/base-sepolia-bridge-credit.json", + + [string]$HandoffOut = "services/bridge-relayer/out/base-sepolia-bridge-handoff.json" ) $ErrorActionPreference = "Stop" @@ -19,12 +23,13 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) Set-Location -LiteralPath $repoRoot -npm run bridge:observe -- ` - --mode base-sepolia ` - --rpc-url $RpcUrl ` - --lockbox-address $LockboxAddress ` - --from-block $FromBlock ` - --to-block $ToBlock ` - --out $Out +powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "bridge-base-sepolia-observe.ps1") ` + -RpcUrl $RpcUrl ` + -LockboxAddress $LockboxAddress ` + -FromBlock $FromBlock ` + -ToBlock $ToBlock ` + -Out $Out ` + -CreditOut $CreditOut ` + -HandoffOut $HandoffOut -Write-Host "Base Sepolia bridge smoke wrote $Out" -ForegroundColor Green +Write-Host "Base Sepolia bridge smoke wrote $Out and $HandoffOut" -ForegroundColor Green diff --git a/infra/scripts/bridge-local-anvil-observe.ps1 b/infra/scripts/bridge-local-anvil-observe.ps1 new file mode 100644 index 00000000..19c87092 --- /dev/null +++ b/infra/scripts/bridge-local-anvil-observe.ps1 @@ -0,0 +1,51 @@ +param( + [string]$RpcUrl = $env:ANVIL_RPC_URL, + + [string]$LockboxAddress = $env:ANVIL_BRIDGE_LOCKBOX_ADDRESS, + + [string]$FromBlock = $env:ANVIL_BRIDGE_FROM_BLOCK, + + [string]$ToBlock = $env:ANVIL_BRIDGE_TO_BLOCK, + + [string]$Out = "services/bridge-relayer/out/local-anvil-bridge-observation.json", + + [string]$CreditOut = "services/bridge-relayer/out/local-anvil-bridge-credit.json", + + [string]$HandoffOut = "services/bridge-relayer/out/local-anvil-bridge-handoff.json" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location -LiteralPath $repoRoot + +if ([string]::IsNullOrWhiteSpace($RpcUrl)) { + $RpcUrl = "http://127.0.0.1:8545" +} + +$missing = @() +if ([string]::IsNullOrWhiteSpace($LockboxAddress)) { $missing += "ANVIL_BRIDGE_LOCKBOX_ADDRESS or -LockboxAddress" } +if ([string]::IsNullOrWhiteSpace($FromBlock)) { $missing += "ANVIL_BRIDGE_FROM_BLOCK or -FromBlock" } +if ([string]::IsNullOrWhiteSpace($ToBlock)) { $missing += "ANVIL_BRIDGE_TO_BLOCK or -ToBlock" } + +if ($missing.Count -gt 0) { + throw "Local Anvil bridge observation needs: $($missing -join ', ')." +} + +Write-Host "Observing local Anvil bridge deposits." -ForegroundColor Cyan +Write-Host "Chain: local Anvil (31337)" +Write-Host "Lockbox: $LockboxAddress" +Write-Host "Block range: $FromBlock-$ToBlock" +Write-Host "Broadcast: false; this command only reads logs." + +npm run bridge:observe -- ` + --mode local-anvil ` + --rpc-url $RpcUrl ` + --lockbox-address $LockboxAddress ` + --from-block $FromBlock ` + --to-block $ToBlock ` + --out $Out ` + --credit-out $CreditOut ` + --handoff-out $HandoffOut + +Write-Host "Local Anvil bridge observation wrote $Out and $HandoffOut" -ForegroundColor Green diff --git a/package.json b/package.json index d8cb0136..4123b248 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "flowchain:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-smoke.ps1", "flowchain:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-export.ps1", "flowchain:import": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-import.ps1", + "flowchain:full-smoke": "npm run flowchain:smoke && npm run bridge:local-credit:smoke", "workbench:dev": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-workbench.ps1", "e2e": "npm run index:fixtures && npm run verify:fixtures && npm run flowmemory:generate", "demo:indexer": "npm run demo --prefix services/indexer", @@ -49,6 +50,9 @@ "control-plane:serve": "npm run serve --prefix services/control-plane", "bridge:test": "npm test --prefix services/bridge-relayer", "bridge:mock": "npm run mock --prefix services/bridge-relayer", + "bridge:sepolia:observe": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-sepolia-observe.ps1", + "bridge:anvil:observe": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-local-anvil-observe.ps1", + "bridge:local-credit:smoke": "npm run local-credit:smoke --prefix services/bridge-relayer", "bridge:observe": "node services/bridge-relayer/src/observe-base-lockbox.ts" }, "devDependencies": { diff --git a/schemas/flowmemory/bridge-credit-set.schema.json b/schemas/flowmemory/bridge-credit-set.schema.json new file mode 100644 index 00000000..c1a55c33 --- /dev/null +++ b/schemas/flowmemory/bridge-credit-set.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-credit-set.schema.json", + "title": "FlowChainBridgeCreditSet", + "type": "object", + "additionalProperties": false, + "required": ["schema", "creditSetId", "generatedAt", "count", "credits", "productionReady"], + "properties": { + "schema": { "const": "flowmemory.bridge_credit_set.v0" }, + "creditSetId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "generatedAt": { "type": "string" }, + "count": { "type": "integer", "minimum": 0 }, + "credits": { + "type": "array", + "items": { "$ref": "bridge-credit.schema.json" } + }, + "productionReady": { "const": false } + } +} diff --git a/schemas/flowmemory/bridge-credit.schema.json b/schemas/flowmemory/bridge-credit.schema.json new file mode 100644 index 00000000..8fdf5480 --- /dev/null +++ b/schemas/flowmemory/bridge-credit.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-credit.schema.json", + "title": "FlowChainBridgeCredit", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "creditId", + "observationId", + "depositId", + "replayKey", + "source", + "token", + "amount", + "flowchainRecipient", + "status", + "localOnly", + "productionReady" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_credit.v0" }, + "creditId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "observationId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "depositId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "replayKey": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["chainId", "contract", "txHash", "logIndex"], + "properties": { + "chainId": { "type": "integer", "enum": [31337, 84532, 8453] }, + "contract": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "txHash": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "logIndex": { "type": "integer", "minimum": 0 } + } + }, + "token": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "amount": { "type": "string", "pattern": "^[0-9]+$" }, + "flowchainRecipient": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "status": { "type": "string", "enum": ["pending", "applied", "rejected"] }, + "pendingReason": { "type": "string" }, + "appliedAt": { "type": "string" }, + "rejectionReason": { "type": "string" }, + "localOnly": { "const": true }, + "productionReady": { "const": false } + } +} diff --git a/schemas/flowmemory/bridge-deposit.schema.json b/schemas/flowmemory/bridge-deposit.schema.json index 05744cf8..dd42d827 100644 --- a/schemas/flowmemory/bridge-deposit.schema.json +++ b/schemas/flowmemory/bridge-deposit.schema.json @@ -21,10 +21,13 @@ "properties": { "schema": { "const": "flowmemory.bridge_deposit.v0" }, "depositId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, - "sourceChainId": { "type": "integer", "enum": [84532, 8453] }, + "sourceChainId": { "type": "integer", "enum": [31337, 84532, 8453] }, "sourceContract": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, "txHash": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, "logIndex": { "type": "integer", "minimum": 0 }, + "sourceBlockNumber": { "type": "string", "pattern": "^[0-9]+$" }, + "sourceBlockHash": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "transactionIndex": { "type": "integer", "minimum": 0 }, "token": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, "amount": { "type": "string", "pattern": "^[0-9]+$" }, "sender": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, diff --git a/schemas/flowmemory/bridge-observation-set.schema.json b/schemas/flowmemory/bridge-observation-set.schema.json new file mode 100644 index 00000000..b85fb578 --- /dev/null +++ b/schemas/flowmemory/bridge-observation-set.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-observation-set.schema.json", + "title": "FlowChainBridgeObservationSet", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "observationSetId", + "observedAt", + "mode", + "productionReady", + "count", + "observations" + ], + "properties": { + "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"] }, + "productionReady": { "const": false }, + "count": { "type": "integer", "minimum": 0 }, + "observations": { + "type": "array", + "items": { "$ref": "bridge-observation.schema.json" } + } + } +} diff --git a/schemas/flowmemory/bridge-observation.schema.json b/schemas/flowmemory/bridge-observation.schema.json index fb29756c..0297b6cb 100644 --- a/schemas/flowmemory/bridge-observation.schema.json +++ b/schemas/flowmemory/bridge-observation.schema.json @@ -7,6 +7,7 @@ "required": [ "schema", "observationId", + "replayKey", "observedAt", "mode", "productionReady", @@ -16,8 +17,9 @@ "properties": { "schema": { "const": "flowmemory.bridge_deposit_observation.v0" }, "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", "base-sepolia", "base-mainnet-canary"] }, + "mode": { "type": "string", "enum": ["mock", "local-anvil", "base-sepolia", "base-mainnet-canary"] }, "productionReady": { "const": false }, "deposit": { "$ref": "bridge-deposit.schema.json" }, "guardrails": { diff --git a/schemas/flowmemory/bridge-runtime-handoff.schema.json b/schemas/flowmemory/bridge-runtime-handoff.schema.json new file mode 100644 index 00000000..f464934e --- /dev/null +++ b/schemas/flowmemory/bridge-runtime-handoff.schema.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-runtime-handoff.schema.json", + "title": "FlowChainBridgeRuntimeHandoff", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "handoffId", + "generatedAt", + "mode", + "productionReady", + "localOnly", + "observations", + "credits", + "withdrawalIntents", + "replayProtection", + "runtimeIntake", + "workbenchTimeline", + "workbenchRecords", + "limitations" + ], + "properties": { + "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"] }, + "productionReady": { "const": false }, + "localOnly": { "const": true }, + "observations": { + "type": "array", + "items": { "$ref": "bridge-observation.schema.json" } + }, + "credits": { + "type": "array", + "items": { "$ref": "bridge-credit.schema.json" } + }, + "withdrawalIntents": { + "type": "array", + "items": { "$ref": "bridge-withdrawal-intent.schema.json" } + }, + "replayProtection": { + "type": "object", + "additionalProperties": false, + "required": ["strategy", "replayKeys", "duplicateReplayKeys"], + "properties": { + "strategy": { "const": "source-chain-contract-tx-log-deposit" }, + "replayKeys": { + "type": "array", + "items": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "uniqueItems": true + }, + "duplicateReplayKeys": { + "type": "array", + "items": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "uniqueItems": true + } + } + }, + "runtimeIntake": { + "type": "object", + "additionalProperties": false, + "required": ["status", "consumer", "expectedPath", "note"], + "properties": { + "status": { "const": "handoff_file" }, + "consumer": { "const": "flowchain-runtime-agent" }, + "expectedPath": { "type": "string" }, + "note": { "type": "string" } + } + }, + "workbenchTimeline": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["phase", "status", "objectId", "title", "summary"], + "properties": { + "phase": { + "type": "string", + "enum": ["deposit_observed", "credit_pending", "credit_applied", "withdrawal_requested"] + }, + "status": { "type": "string", "enum": ["observed", "pending", "applied", "requested"] }, + "objectId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "title": { "type": "string" }, + "summary": { "type": "string" } + } + } + }, + "workbenchRecords": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["sectionKey", "id", "kind", "title", "summary", "status", "facts", "rawRef"], + "properties": { + "sectionKey": { "type": "string", "enum": ["transactions", "receipts", "finality", "rawJson"] }, + "id": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "kind": { "type": "string" }, + "title": { "type": "string" }, + "summary": { "type": "string" }, + "status": { "type": "string", "enum": ["observed", "pending", "verified"] }, + "facts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["label", "value"], + "properties": { + "label": { "type": "string" }, + "value": { "type": "string" } + } + } + }, + "rawRef": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" } + } + } + }, + "limitations": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/schemas/flowmemory/bridge-withdrawal-intent-set.schema.json b/schemas/flowmemory/bridge-withdrawal-intent-set.schema.json new file mode 100644 index 00000000..cb14b3d6 --- /dev/null +++ b/schemas/flowmemory/bridge-withdrawal-intent-set.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-withdrawal-intent-set.schema.json", + "title": "FlowChainBridgeWithdrawalIntentSet", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "withdrawalIntentSetId", + "generatedAt", + "count", + "withdrawalIntents", + "productionReady" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_withdrawal_intent_set.v0" }, + "withdrawalIntentSetId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "generatedAt": { "type": "string" }, + "count": { "type": "integer", "minimum": 0 }, + "withdrawalIntents": { + "type": "array", + "items": { "$ref": "bridge-withdrawal-intent.schema.json" } + }, + "productionReady": { "const": false } + } +} diff --git a/schemas/flowmemory/bridge-withdrawal-intent.schema.json b/schemas/flowmemory/bridge-withdrawal-intent.schema.json new file mode 100644 index 00000000..72e12e43 --- /dev/null +++ b/schemas/flowmemory/bridge-withdrawal-intent.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flowmemory.local/schemas/flowmemory/bridge-withdrawal-intent.schema.json", + "title": "FlowChainBridgeWithdrawalIntent", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "withdrawalIntentId", + "creditId", + "depositId", + "sourceChainId", + "destinationChainId", + "token", + "amount", + "flowchainAccount", + "baseRecipient", + "status", + "requestedAt", + "testMode", + "broadcast", + "releasePolicy", + "productionReady" + ], + "properties": { + "schema": { "const": "flowmemory.bridge_withdrawal_intent.v0" }, + "withdrawalIntentId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "creditId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "depositId": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "sourceChainId": { "type": "integer", "enum": [31337, 84532, 8453] }, + "destinationChainId": { "type": "integer", "enum": [31337, 84532, 8453] }, + "token": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "amount": { "type": "string", "pattern": "^[0-9]+$" }, + "flowchainAccount": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "baseRecipient": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "status": { "type": "string", "enum": ["requested", "cancelled", "released_test_record", "rejected"] }, + "requestedAt": { "type": "string" }, + "testMode": { "const": true }, + "broadcast": { "const": false }, + "releasePolicy": { "const": "test_record_only" }, + "productionReady": { "const": false } + } +} diff --git a/services/bridge-relayer/README.md b/services/bridge-relayer/README.md index f8a9b5a1..970ed5d1 100644 --- a/services/bridge-relayer/README.md +++ b/services/bridge-relayer/README.md @@ -3,8 +3,9 @@ Status: fixture-first bridge observer for local/Base Sepolia testing. This package converts explicit `BaseBridgeLockbox` deposit records into -FlowChain bridge observation JSON. It does not custody funds, sign releases, run -a production relayer, or prove finality. +FlowChain bridge observation, credit, withdrawal-intent, and local runtime +handoff JSON. It does not custody funds, sign releases, run a production +relayer, or prove finality. Local mock: @@ -12,6 +13,18 @@ Local mock: npm run bridge:mock ``` +Local credit smoke: + +```powershell +npm run bridge:local-credit:smoke +``` + +This writes the current runtime-agent handoff file: + +```text +fixtures/bridge/local-runtime-bridge-handoff.json +``` + Base Sepolia guarded smoke: ```powershell @@ -22,6 +35,29 @@ powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/bridge-base-se -ToBlock ``` +Base Sepolia observation from root package env vars: + +```powershell +$env:BASE_SEPOLIA_RPC_URL="" +$env:BASE_BRIDGE_LOCKBOX_ADDRESS="" +$env:BASE_BRIDGE_FROM_BLOCK="" +$env:BASE_BRIDGE_TO_BLOCK="" +npm run bridge:sepolia:observe +``` + +No private key is required. The command reads `BridgeDeposit` logs over an +explicit block range and writes observation, credit, and handoff JSON under +`services/bridge-relayer/out/`. + +Local Anvil observation uses the same log decoder with chain id `31337`: + +```powershell +$env:ANVIL_BRIDGE_LOCKBOX_ADDRESS="" +$env:ANVIL_BRIDGE_FROM_BLOCK="" +$env:ANVIL_BRIDGE_TO_BLOCK="" +npm run bridge:anvil:observe +``` + 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. diff --git a/services/bridge-relayer/package.json b/services/bridge-relayer/package.json index 5319660f..d6580122 100644 --- a/services/bridge-relayer/package.json +++ b/services/bridge-relayer/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "scripts": { - "mock": "node src/observe-base-lockbox.ts --fixture ../../fixtures/bridge/base-sepolia-mock-deposit.json --out out/bridge-observation.json", + "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", + "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/observe-base-lockbox.ts b/services/bridge-relayer/src/observe-base-lockbox.ts index dcf09fec..c4234953 100644 --- a/services/bridge-relayer/src/observe-base-lockbox.ts +++ b/services/bridge-relayer/src/observe-base-lockbox.ts @@ -2,20 +2,46 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { canonicalJson, keccak256Utf8 } from "../../shared/src/index.ts"; +import { + canonicalJson, + decodeAddressTopic, + decodeBytes32Word, + decodeUint256Word, + hexToBytes, + keccak256Utf8, + normalizeAddress, + normalizeBytes32, +} from "../../shared/src/index.ts"; export const BASE_MAINNET_CHAIN_ID = 8453; export const BASE_SEPOLIA_CHAIN_ID = 84532; +export const LOCAL_ANVIL_CHAIN_ID = 31337; export const MAX_CANARY_USD = 25; export const MAX_BLOCK_RANGE = 5_000n; +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); +export const FIXED_TEST_OBSERVED_AT = "2026-05-13T00:00:00.000Z"; + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue | undefined }; + +export type BridgeSourceChainId = + | typeof LOCAL_ANVIL_CHAIN_ID + | typeof BASE_SEPOLIA_CHAIN_ID + | typeof BASE_MAINNET_CHAIN_ID; + +export type BridgeMode = "mock" | "local-anvil" | "base-sepolia" | "base-mainnet-canary"; export interface BridgeDeposit { schema: "flowmemory.bridge_deposit.v0"; depositId: `0x${string}`; - sourceChainId: 84532 | 8453; + sourceChainId: BridgeSourceChainId; sourceContract: `0x${string}`; txHash: `0x${string}`; logIndex: number; + sourceBlockNumber?: string; + sourceBlockHash?: `0x${string}`; + transactionIndex?: number; token: `0x${string}`; amount: string; sender: `0x${string}`; @@ -28,8 +54,9 @@ export interface BridgeDeposit { export interface BridgeObservation { schema: "flowmemory.bridge_deposit_observation.v0"; observationId: `0x${string}`; + replayKey: `0x${string}`; observedAt: string; - mode: "mock" | "base-sepolia" | "base-mainnet-canary"; + mode: BridgeMode; productionReady: false; deposit: BridgeDeposit; guardrails: { @@ -41,16 +68,157 @@ export interface BridgeObservation { }; } +export interface BridgeObservationSet { + schema: "flowmemory.bridge_observation_set.v0"; + observationSetId: `0x${string}`; + observedAt: string; + mode: BridgeMode; + productionReady: false; + count: number; + observations: BridgeObservation[]; +} + +export interface BridgeCredit { + schema: "flowmemory.bridge_credit.v0"; + creditId: `0x${string}`; + observationId: `0x${string}`; + depositId: `0x${string}`; + replayKey: `0x${string}`; + source: { + chainId: BridgeSourceChainId; + contract: `0x${string}`; + txHash: `0x${string}`; + logIndex: number; + }; + token: `0x${string}`; + amount: string; + flowchainRecipient: `0x${string}`; + status: "pending" | "applied" | "rejected"; + pendingReason?: string; + appliedAt?: string; + rejectionReason?: string; + localOnly: true; + productionReady: false; +} + +export interface BridgeCreditSet { + schema: "flowmemory.bridge_credit_set.v0"; + creditSetId: `0x${string}`; + generatedAt: string; + count: number; + credits: BridgeCredit[]; + productionReady: false; +} + +export interface BridgeWithdrawalIntent { + schema: "flowmemory.bridge_withdrawal_intent.v0"; + withdrawalIntentId: `0x${string}`; + creditId: `0x${string}`; + depositId: `0x${string}`; + sourceChainId: BridgeSourceChainId; + destinationChainId: BridgeSourceChainId; + token: `0x${string}`; + amount: string; + flowchainAccount: `0x${string}`; + baseRecipient: `0x${string}`; + status: "requested" | "cancelled" | "released_test_record" | "rejected"; + requestedAt: string; + testMode: true; + broadcast: false; + releasePolicy: "test_record_only"; + productionReady: false; +} + +export interface BridgeWithdrawalIntentSet { + schema: "flowmemory.bridge_withdrawal_intent_set.v0"; + withdrawalIntentSetId: `0x${string}`; + generatedAt: string; + count: number; + withdrawalIntents: BridgeWithdrawalIntent[]; + productionReady: false; +} + +export interface BridgeRuntimeHandoff { + schema: "flowmemory.bridge_runtime_handoff.v0"; + handoffId: `0x${string}`; + generatedAt: string; + mode: BridgeMode; + productionReady: false; + localOnly: true; + observations: BridgeObservation[]; + credits: BridgeCredit[]; + withdrawalIntents: BridgeWithdrawalIntent[]; + replayProtection: { + strategy: "source-chain-contract-tx-log-deposit"; + replayKeys: `0x${string}`[]; + duplicateReplayKeys: `0x${string}`[]; + }; + runtimeIntake: { + status: "handoff_file"; + consumer: "flowchain-runtime-agent"; + expectedPath: string; + note: string; + }; + workbenchTimeline: { + phase: "deposit_observed" | "credit_pending" | "credit_applied" | "withdrawal_requested"; + status: "observed" | "pending" | "applied" | "requested"; + objectId: `0x${string}`; + title: string; + summary: string; + }[]; + workbenchRecords: { + sectionKey: "transactions" | "receipts" | "finality" | "rawJson"; + id: `0x${string}`; + kind: string; + title: string; + summary: string; + status: "observed" | "pending" | "verified"; + facts: { label: string; value: string }[]; + rawRef: `0x${string}`; + }[]; + limitations: string[]; +} + interface CliOptions { - mode: "mock" | "base-sepolia" | "base-mainnet-canary"; + mode: BridgeMode; fixturePath?: string; outPath: string; + creditOutPath?: string; + handoffOutPath?: string; + withdrawalOutPath?: string; rpcUrl?: string; lockboxAddress?: `0x${string}`; fromBlock?: string; toBlock?: string; + expectedChainId?: BridgeSourceChainId; acknowledgeRealFunds: boolean; maxUsd?: number; + applyCredit: boolean; + withdrawalIntent: boolean; + withdrawalBaseRecipient?: `0x${string}`; +} + +export interface BridgePipelineResult { + observations: BridgeObservation[]; + credits: BridgeCredit[]; + withdrawalIntents: BridgeWithdrawalIntent[]; + handoff: BridgeRuntimeHandoff; +} + +interface RpcLog { + address: string; + topics: string[]; + data: string; + blockNumber?: string; + blockHash?: string; + transactionHash: string; + transactionIndex?: string; + logIndex: string; + removed?: boolean; +} + +function stableId(schema: string, value: JsonValue): `0x${string}` { + return keccak256Utf8(canonicalJson({ schema, value })); } function argValue(args: string[], index: number, name: string): string { @@ -62,17 +230,35 @@ function argValue(args: string[], index: number, name: string): string { } function asAddress(value: string, name: string): `0x${string}` { - if (!/^0x[0-9a-fA-F]{40}$/.test(value)) { + try { + return normalizeAddress(value) as `0x${string}`; + } catch { throw new Error(`${name} must be a 20-byte hex address`); } - return value.toLowerCase() as `0x${string}`; } function asHash(value: string, name: string): `0x${string}` { - if (!/^0x[0-9a-fA-F]{64}$/.test(value)) { + try { + return normalizeBytes32(value) as `0x${string}`; + } catch { throw new Error(`${name} must be a 32-byte hex value`); } - return value.toLowerCase() as `0x${string}`; +} + +function asDecimalString(value: unknown, name: string): string { + const text = String(value); + if (!/^[0-9]+$/.test(text)) { + throw new Error(`${name} must be a decimal string`); + } + return text; +} + +function asNonNegativeInteger(value: unknown, name: string): number { + const number = Number(value); + if (!Number.isInteger(number) || number < 0) { + throw new Error(`${name} must be a non-negative integer`); + } + return number; } function asBlock(value: string, name: string): bigint { @@ -82,23 +268,60 @@ function asBlock(value: string, name: string): bigint { return BigInt(value); } +function asSourceChainId(value: unknown, name: string): BridgeSourceChainId { + const chainId = Number(value); + if ( + chainId !== LOCAL_ANVIL_CHAIN_ID + && chainId !== BASE_SEPOLIA_CHAIN_ID + && chainId !== BASE_MAINNET_CHAIN_ID + ) { + throw new Error(`${name} must be ${LOCAL_ANVIL_CHAIN_ID}, ${BASE_SEPOLIA_CHAIN_ID}, or ${BASE_MAINNET_CHAIN_ID}`); + } + return chainId as BridgeSourceChainId; +} + +function expectedChainIdForMode(mode: BridgeMode, explicit?: BridgeSourceChainId): BridgeSourceChainId { + if (explicit !== undefined) { + return explicit; + } + if (mode === "local-anvil") { + return LOCAL_ANVIL_CHAIN_ID; + } + if (mode === "base-sepolia") { + return BASE_SEPOLIA_CHAIN_ID; + } + return BASE_MAINNET_CHAIN_ID; +} + export function parseBridgeArgs(args: string[]): CliOptions { let mode: CliOptions["mode"] = "mock"; let fixturePath: string | undefined; let outPath = "out/bridge-observation.json"; + let creditOutPath: string | undefined; + let handoffOutPath: string | undefined; + let withdrawalOutPath: string | undefined; let rpcUrl: string | undefined; let lockboxAddress: `0x${string}` | undefined; let fromBlock: string | undefined; let toBlock: string | undefined; + let expectedChainId: BridgeSourceChainId | undefined; let acknowledgeRealFunds = false; let maxUsd: number | undefined; + let applyCredit = false; + let withdrawalIntent = false; + let withdrawalBaseRecipient: `0x${string}` | undefined; 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" && value !== "base-sepolia" && value !== "base-mainnet-canary") { - throw new Error("--mode must be mock, base-sepolia, or base-mainnet-canary"); + if ( + value !== "mock" + && value !== "local-anvil" + && value !== "base-sepolia" + && value !== "base-mainnet-canary" + ) { + throw new Error("--mode must be mock, local-anvil, base-sepolia, or base-mainnet-canary"); } mode = value; index += 1; @@ -108,6 +331,15 @@ export function parseBridgeArgs(args: string[]): CliOptions { } else if (arg === "--out") { outPath = argValue(args, index, arg); index += 1; + } else if (arg === "--credit-out") { + creditOutPath = argValue(args, index, arg); + index += 1; + } else if (arg === "--handoff-out") { + handoffOutPath = argValue(args, index, arg); + index += 1; + } else if (arg === "--withdrawal-out") { + withdrawalOutPath = argValue(args, index, arg); + index += 1; } else if (arg === "--rpc-url") { rpcUrl = argValue(args, index, arg); index += 1; @@ -120,11 +352,21 @@ export function parseBridgeArgs(args: string[]): CliOptions { } else if (arg === "--to-block") { toBlock = argValue(args, index, arg); index += 1; + } else if (arg === "--expected-chain-id") { + expectedChainId = asSourceChainId(argValue(args, index, arg), arg); + index += 1; } else if (arg === "--acknowledge-real-funds") { acknowledgeRealFunds = true; } else if (arg === "--max-usd") { maxUsd = Number(argValue(args, index, arg)); index += 1; + } else if (arg === "--apply-credit") { + applyCredit = true; + } else if (arg === "--withdrawal-intent") { + withdrawalIntent = true; + } else if (arg === "--withdrawal-base-recipient") { + withdrawalBaseRecipient = asAddress(argValue(args, index, arg), arg); + index += 1; } else { throw new Error(`unknown argument: ${arg}`); } @@ -136,7 +378,7 @@ export function parseBridgeArgs(args: string[]): CliOptions { if (mode !== "mock") { if (!rpcUrl || !lockboxAddress || !fromBlock || !toBlock) { - throw new Error("--rpc-url, --lockbox-address, --from-block, and --to-block are required for Base reads"); + throw new Error("--rpc-url, --lockbox-address, --from-block, and --to-block are required for RPC reads"); } const from = asBlock(fromBlock, "--from-block"); const to = asBlock(toBlock, "--to-block"); @@ -161,12 +403,19 @@ export function parseBridgeArgs(args: string[]): CliOptions { mode, fixturePath, outPath, + creditOutPath, + handoffOutPath, + withdrawalOutPath, rpcUrl, lockboxAddress, fromBlock, toBlock, + expectedChainId, acknowledgeRealFunds, maxUsd, + applyCredit, + withdrawalIntent, + withdrawalBaseRecipient, }; } @@ -178,36 +427,72 @@ export function validateDeposit(value: unknown): BridgeDeposit { if (deposit.schema !== "flowmemory.bridge_deposit.v0") { throw new Error("unsupported bridge deposit schema"); } + const status = deposit.status; + if (status !== "observed") { + throw new Error("fixture status must be observed"); + } + return { schema: "flowmemory.bridge_deposit.v0", depositId: asHash(String(deposit.depositId), "depositId"), - sourceChainId: deposit.sourceChainId === 8453 ? 8453 : deposit.sourceChainId === 84532 ? 84532 : (() => { - throw new Error("sourceChainId must be 84532 or 8453"); - })(), + sourceChainId: asSourceChainId(deposit.sourceChainId, "sourceChainId"), sourceContract: asAddress(String(deposit.sourceContract), "sourceContract"), txHash: asHash(String(deposit.txHash), "txHash"), - logIndex: Number(deposit.logIndex), + logIndex: asNonNegativeInteger(deposit.logIndex, "logIndex"), + sourceBlockNumber: deposit.sourceBlockNumber === undefined + ? undefined + : asDecimalString(deposit.sourceBlockNumber, "sourceBlockNumber"), + sourceBlockHash: deposit.sourceBlockHash === undefined + ? undefined + : asHash(String(deposit.sourceBlockHash), "sourceBlockHash"), + transactionIndex: deposit.transactionIndex === undefined + ? undefined + : asNonNegativeInteger(deposit.transactionIndex, "transactionIndex"), token: asAddress(String(deposit.token), "token"), - amount: String(deposit.amount), + amount: asDecimalString(deposit.amount, "amount"), sender: asAddress(String(deposit.sender), "sender"), flowchainRecipient: asHash(String(deposit.flowchainRecipient), "flowchainRecipient"), - nonce: String(deposit.nonce), + nonce: asDecimalString(deposit.nonce, "nonce"), metadataHash: deposit.metadataHash === undefined ? undefined : asHash(String(deposit.metadataHash), "metadataHash"), - status: deposit.status === "observed" ? "observed" : (() => { - throw new Error("fixture status must be observed"); - })(), + status, }; } +function fixtureDeposits(fixture: unknown): BridgeDeposit[] { + if (fixture !== null && typeof fixture === "object" && !Array.isArray(fixture)) { + const maybeBatch = fixture as Record; + if (Array.isArray(maybeBatch.deposits)) { + return maybeBatch.deposits.map((entry) => validateDeposit(entry)); + } + } + return [validateDeposit(fixture)]; +} + +export function bridgeReplayKey(deposit: BridgeDeposit): `0x${string}` { + return stableId("flowmemory.bridge_replay_key.v0", { + sourceChainId: deposit.sourceChainId, + sourceContract: deposit.sourceContract, + txHash: deposit.txHash, + logIndex: deposit.logIndex, + depositId: deposit.depositId, + }); +} + export function makeObservation( deposit: BridgeDeposit, mode: BridgeObservation["mode"], maxUsd?: number, ): BridgeObservation { + const replayKey = bridgeReplayKey(deposit); return { schema: "flowmemory.bridge_deposit_observation.v0", - observationId: keccak256Utf8(canonicalJson({ deposit, mode })) as `0x${string}`, - observedAt: "2026-05-13T00:00:00.000Z", + observationId: stableId("flowmemory.bridge_observation.v0", { + mode, + replayKey, + depositId: deposit.depositId, + }), + replayKey, + observedAt: FIXED_TEST_OBSERVED_AT, mode, productionReady: false, deposit, @@ -221,60 +506,487 @@ export function makeObservation( }; } -async function readChainId(rpcUrl: string): Promise { +export function makeObservationSet(observations: BridgeObservation[], mode: BridgeMode): BridgeObservationSet { + return { + schema: "flowmemory.bridge_observation_set.v0", + observationSetId: stableId("flowmemory.bridge_observation_set.v0", { + mode, + observationIds: observations.map((observation) => observation.observationId), + }), + observedAt: FIXED_TEST_OBSERVED_AT, + mode, + productionReady: false, + count: observations.length, + observations, + }; +} + +export function makeBridgeCredit( + observation: BridgeObservation, + status: BridgeCredit["status"] = "pending", + rejectionReason?: string, +): BridgeCredit { + const deposit = observation.deposit; + return { + schema: "flowmemory.bridge_credit.v0", + creditId: stableId("flowmemory.bridge_credit.v0", { + observationId: observation.observationId, + depositId: deposit.depositId, + replayKey: observation.replayKey, + sourceChainId: deposit.sourceChainId, + sourceContract: deposit.sourceContract, + txHash: deposit.txHash, + logIndex: deposit.logIndex, + }), + observationId: observation.observationId, + depositId: deposit.depositId, + replayKey: observation.replayKey, + source: { + chainId: deposit.sourceChainId, + contract: deposit.sourceContract, + txHash: deposit.txHash, + logIndex: deposit.logIndex, + }, + token: deposit.token, + amount: deposit.amount, + flowchainRecipient: deposit.flowchainRecipient, + status, + pendingReason: status === "pending" ? "runtime_intake_pending_handoff_file" : undefined, + appliedAt: status === "applied" ? FIXED_TEST_OBSERVED_AT : undefined, + rejectionReason, + localOnly: true, + productionReady: false, + }; +} + +export function makeCreditSet(credits: BridgeCredit[]): BridgeCreditSet { + return { + schema: "flowmemory.bridge_credit_set.v0", + creditSetId: stableId("flowmemory.bridge_credit_set.v0", { + creditIds: credits.map((credit) => credit.creditId), + }), + generatedAt: FIXED_TEST_OBSERVED_AT, + count: credits.length, + credits, + productionReady: false, + }; +} + +function makeCredits(observations: BridgeObservation[], applyCredit: boolean): BridgeCredit[] { + const seen = new Set<`0x${string}`>(); + return observations.map((observation) => { + if (seen.has(observation.replayKey)) { + return makeBridgeCredit(observation, "rejected", "duplicate_replay_key"); + } + seen.add(observation.replayKey); + return makeBridgeCredit(observation, applyCredit ? "applied" : "pending"); + }); +} + +export function makeWithdrawalIntent( + credit: BridgeCredit, + deposit: BridgeDeposit, + baseRecipient: `0x${string}` = deposit.sender, +): BridgeWithdrawalIntent { + return { + schema: "flowmemory.bridge_withdrawal_intent.v0", + withdrawalIntentId: stableId("flowmemory.bridge_withdrawal_intent.v0", { + creditId: credit.creditId, + depositId: credit.depositId, + destinationChainId: deposit.sourceChainId, + token: credit.token, + amount: credit.amount, + flowchainAccount: credit.flowchainRecipient, + baseRecipient, + testMode: true, + }), + creditId: credit.creditId, + depositId: credit.depositId, + sourceChainId: deposit.sourceChainId, + destinationChainId: deposit.sourceChainId, + token: credit.token, + amount: credit.amount, + flowchainAccount: credit.flowchainRecipient, + baseRecipient, + status: "requested", + requestedAt: FIXED_TEST_OBSERVED_AT, + testMode: true, + broadcast: false, + releasePolicy: "test_record_only", + productionReady: false, + }; +} + +export function makeWithdrawalIntentSet(withdrawalIntents: BridgeWithdrawalIntent[]): BridgeWithdrawalIntentSet { + return { + schema: "flowmemory.bridge_withdrawal_intent_set.v0", + withdrawalIntentSetId: stableId("flowmemory.bridge_withdrawal_intent_set.v0", { + withdrawalIntentIds: withdrawalIntents.map((intent) => intent.withdrawalIntentId), + }), + generatedAt: FIXED_TEST_OBSERVED_AT, + count: withdrawalIntents.length, + withdrawalIntents, + productionReady: false, + }; +} + +function duplicateReplayKeys(observations: BridgeObservation[]): `0x${string}`[] { + const seen = new Set<`0x${string}`>(); + const duplicates = new Set<`0x${string}`>(); + for (const observation of observations) { + if (seen.has(observation.replayKey)) { + duplicates.add(observation.replayKey); + } + seen.add(observation.replayKey); + } + return [...duplicates].sort(); +} + +export function makeRuntimeHandoff( + mode: BridgeMode, + observations: BridgeObservation[], + credits: BridgeCredit[], + withdrawalIntents: BridgeWithdrawalIntent[], + expectedPath = "fixtures/bridge/local-runtime-bridge-handoff.json", +): BridgeRuntimeHandoff { + const normalizedExpectedPath = normalizeHandoffExpectedPath(expectedPath); + const replayKeys = [...new Set(observations.map((observation) => observation.replayKey))].sort() as `0x${string}`[]; + const firstObservation = observations[0]; + const firstCredit = credits[0]; + const firstAppliedCredit = credits.find((credit) => credit.status === "applied"); + const firstWithdrawal = withdrawalIntents[0]; + + const workbenchTimeline: BridgeRuntimeHandoff["workbenchTimeline"] = []; + if (firstObservation !== undefined) { + workbenchTimeline.push({ + phase: "deposit_observed", + status: "observed", + objectId: firstObservation.observationId, + title: "Deposit observed", + summary: `Observed lockbox deposit ${firstObservation.deposit.depositId} on chain ${firstObservation.deposit.sourceChainId}.`, + }); + } + if (firstCredit !== undefined) { + workbenchTimeline.push({ + phase: "credit_pending", + status: "pending", + objectId: firstCredit.creditId, + title: "Credit pending", + summary: `${firstCredit.amount} test units queued for ${firstCredit.flowchainRecipient}.`, + }); + } + if (firstAppliedCredit !== undefined) { + workbenchTimeline.push({ + phase: "credit_applied", + status: "applied", + objectId: firstAppliedCredit.creditId, + title: "Credit applied", + summary: `${firstAppliedCredit.amount} test units applied in local bridge smoke state.`, + }); + } + if (firstWithdrawal !== undefined) { + workbenchTimeline.push({ + phase: "withdrawal_requested", + status: "requested", + objectId: firstWithdrawal.withdrawalIntentId, + title: "Withdrawal requested", + summary: "Test-mode local-to-Base withdrawal intent recorded with no broadcast or real release.", + }); + } + + const workbenchRecords: BridgeRuntimeHandoff["workbenchRecords"] = [ + ...observations.map((observation) => ({ + sectionKey: "transactions" as const, + id: observation.observationId, + kind: "Bridge deposit observation", + title: observation.deposit.txHash, + summary: `Deposit ${observation.deposit.depositId} observed from ${observation.mode}.`, + status: "observed" as const, + facts: [ + { label: "chain", value: String(observation.deposit.sourceChainId) }, + { label: "lockbox", value: observation.deposit.sourceContract }, + { label: "log index", value: String(observation.deposit.logIndex) }, + { label: "amount", value: observation.deposit.amount }, + ], + rawRef: observation.observationId, + })), + ...credits.map((credit) => ({ + sectionKey: "receipts" as const, + id: credit.creditId, + kind: "Bridge credit", + title: credit.creditId, + summary: `Credit ${credit.status} for deposit ${credit.depositId}.`, + status: credit.status === "applied" ? "verified" as const : credit.status === "pending" ? "pending" as const : "observed" as const, + facts: [ + { label: "recipient", value: credit.flowchainRecipient }, + { label: "amount", value: credit.amount }, + { label: "token", value: credit.token }, + { label: "replay key", value: credit.replayKey }, + ], + rawRef: credit.creditId, + })), + ...withdrawalIntents.map((intent) => ({ + sectionKey: "transactions" as const, + id: intent.withdrawalIntentId, + kind: "Bridge withdrawal intent", + title: intent.withdrawalIntentId, + summary: "Test-mode withdrawal intent recorded; no mainnet or real-funds release is broadcast.", + status: "pending" as const, + facts: [ + { label: "base recipient", value: intent.baseRecipient }, + { label: "amount", value: intent.amount }, + { label: "broadcast", value: String(intent.broadcast) }, + { label: "policy", value: intent.releasePolicy }, + ], + rawRef: intent.withdrawalIntentId, + })), + ]; + + return { + schema: "flowmemory.bridge_runtime_handoff.v0", + handoffId: stableId("flowmemory.bridge_runtime_handoff.v0", { + mode, + observationIds: observations.map((observation) => observation.observationId), + creditIds: credits.map((credit) => credit.creditId), + withdrawalIntentIds: withdrawalIntents.map((intent) => intent.withdrawalIntentId), + }), + generatedAt: FIXED_TEST_OBSERVED_AT, + mode, + productionReady: false, + localOnly: true, + observations, + credits, + withdrawalIntents, + replayProtection: { + strategy: "source-chain-contract-tx-log-deposit", + replayKeys, + duplicateReplayKeys: duplicateReplayKeys(observations), + }, + runtimeIntake: { + status: "handoff_file", + consumer: "flowchain-runtime-agent", + expectedPath: normalizedExpectedPath, + note: "Runtime/control-plane bridge intake is not merged in this scope. Consume this file as the deterministic bridge credit handoff.", + }, + workbenchTimeline, + workbenchRecords, + limitations: [ + "Bridge objects are for mock, local Anvil, and Base Sepolia test validation by default.", + "No production bridge readiness, audited security, or trustless finality is claimed.", + "Withdrawal intents are test-mode records only and do not broadcast releases.", + "RPC URLs and private keys are never written to bridge artifacts.", + ], + }; +} + +function normalizeHandoffExpectedPath(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const marker = "fixtures/bridge/"; + const markerIndex = normalized.lastIndexOf(marker); + return markerIndex >= 0 ? normalized.slice(markerIndex) : normalized; +} + +function hexQuantityToBigInt(value: string, name: string): bigint { + if (!/^0x[0-9a-fA-F]+$/.test(value)) { + throw new Error(`${name} must be an RPC hex quantity`); + } + return BigInt(value); +} + +function hexQuantityToDecimalString(value: string | undefined, name: string): string | undefined { + if (value === undefined) { + return undefined; + } + return hexQuantityToBigInt(value, name).toString(); +} + +function hexQuantityToNumber(value: string | undefined, name: string): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number(hexQuantityToBigInt(value, name)); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`${name} exceeds safe integer range`); + } + return parsed; +} + +function addressFromAbiWord(word: `0x${string}`, name: string): `0x${string}` { + return asAddress(`0x${word.slice(-40)}`, name); +} + +export function parseBridgeDepositLog(log: RpcLog, expectedChainId: BridgeSourceChainId): BridgeDeposit { + if (log.removed) { + throw new Error("removed bridge logs must be handled by a reorg-aware reader"); + } + if (log.topics[0]?.toLowerCase() !== BRIDGE_DEPOSIT_TOPIC0) { + throw new Error("log is not a BaseBridgeLockbox BridgeDeposit event"); + } + const data = hexToBytes(log.data); + if (data.length !== 5 * 32) { + throw new Error(`BridgeDeposit log data must contain 5 ABI words, got ${data.length / 32}`); + } + + const eventChainId = asSourceChainId(Number(BigInt(asHash(log.topics[2] ?? "", "sourceChainId"))), "sourceChainId"); + if (eventChainId !== expectedChainId) { + throw new Error(`BridgeDeposit event chain id mismatch: expected ${expectedChainId}, got ${eventChainId}`); + } + + return { + schema: "flowmemory.bridge_deposit.v0", + depositId: asHash(log.topics[1] ?? "", "depositId"), + sourceChainId: eventChainId, + sourceContract: asAddress(log.address, "sourceContract"), + txHash: asHash(log.transactionHash, "txHash"), + logIndex: Number(hexQuantityToBigInt(log.logIndex, "logIndex")), + sourceBlockNumber: hexQuantityToDecimalString(log.blockNumber, "blockNumber"), + sourceBlockHash: log.blockHash === undefined ? undefined : asHash(log.blockHash, "blockHash"), + transactionIndex: hexQuantityToNumber(log.transactionIndex, "transactionIndex"), + token: addressFromAbiWord(decodeBytes32Word(data, 0), "token"), + amount: decodeUint256Word(data, 1).toString(), + sender: asAddress(decodeAddressTopic(log.topics[3] ?? ""), "sender"), + flowchainRecipient: decodeBytes32Word(data, 2), + nonce: decodeUint256Word(data, 3).toString(), + metadataHash: decodeBytes32Word(data, 4), + status: "observed", + }; +} + +async function rpcCall(rpcUrl: string, method: string, params: JsonValue[]): Promise { const response = await fetch(rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }), + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), }); - const payload = await response.json() as { result?: string; error?: unknown }; - if (!response.ok || !payload.result) { - throw new Error("failed to read chain id from explicit RPC URL"); + const payload = await response.json() as { result?: T; error?: { message?: string } }; + if (!response.ok || payload.error !== undefined || payload.result === undefined) { + throw new Error(`RPC ${method} failed: ${payload.error?.message ?? response.statusText}`); } - return Number(BigInt(payload.result)); + return payload.result; } -export async function runBridgeObserver(options: CliOptions): Promise { - if (options.mode === "mock") { - const fixture = JSON.parse(readFileSync(resolve(options.fixturePath ?? ""), "utf8")) as unknown; - return makeObservation(validateDeposit(fixture), "mock"); - } +function blockTag(value: string): string { + return `0x${BigInt(value).toString(16)}`; +} - const expectedChainId = options.mode === "base-sepolia" ? BASE_SEPOLIA_CHAIN_ID : BASE_MAINNET_CHAIN_ID; +async function readChainId(rpcUrl: string): Promise { + const result = await rpcCall(rpcUrl, "eth_chainId", []); + return Number(BigInt(result)); +} + +async function readBridgeDepositLogs(options: CliOptions): Promise { + const expectedChainId = expectedChainIdForMode(options.mode, options.expectedChainId); const actualChainId = await readChainId(options.rpcUrl ?? ""); if (actualChainId !== expectedChainId) { throw new Error(`wrong chain id: expected ${expectedChainId}, got ${actualChainId}`); } - const syntheticDeposit: BridgeDeposit = { - schema: "flowmemory.bridge_deposit.v0", - depositId: keccak256Utf8(canonicalJson({ - chainId: expectedChainId, - lockbox: options.lockboxAddress, - fromBlock: options.fromBlock, - toBlock: options.toBlock, - })) as `0x${string}`, - sourceChainId: expectedChainId, - sourceContract: options.lockboxAddress ?? "0x0000000000000000000000000000000000000000", - txHash: "0x0000000000000000000000000000000000000000000000000000000000000000", - logIndex: 0, - token: "0x0000000000000000000000000000000000000000", - amount: "0", - sender: "0x0000000000000000000000000000000000000000", - flowchainRecipient: "0x0000000000000000000000000000000000000000000000000000000000000000", - nonce: "0", - metadataHash: "0x0000000000000000000000000000000000000000000000000000000000000000", - status: "observed", + const logs = await rpcCall(options.rpcUrl ?? "", "eth_getLogs", [{ + address: options.lockboxAddress, + fromBlock: blockTag(options.fromBlock ?? "0"), + toBlock: blockTag(options.toBlock ?? "0"), + topics: [BRIDGE_DEPOSIT_TOPIC0], + }]); + + return logs + .filter((log) => !log.removed) + .map((log) => parseBridgeDepositLog(log, expectedChainId)); +} + +export async function runBridgePipeline(options: CliOptions): Promise { + const deposits = options.mode === "mock" + ? fixtureDeposits(JSON.parse(readFileSync(resolve(options.fixturePath ?? ""), "utf8")) as unknown) + : await readBridgeDepositLogs(options); + + const observations = deposits.map((deposit) => makeObservation(deposit, options.mode, options.maxUsd)); + const credits = makeCredits(observations, options.applyCredit); + const withdrawalIntents = options.withdrawalIntent + ? credits + .filter((credit) => credit.status === "applied") + .map((credit) => { + const deposit = observations.find((observation) => observation.deposit.depositId === credit.depositId)?.deposit; + if (deposit === undefined) { + throw new Error(`missing deposit for credit ${credit.creditId}`); + } + return makeWithdrawalIntent(credit, deposit, options.withdrawalBaseRecipient); + }) + : []; + const handoff = makeRuntimeHandoff(options.mode, observations, credits, withdrawalIntents, options.handoffOutPath); + + return { + observations, + credits, + withdrawalIntents, + handoff, }; +} - return makeObservation(syntheticDeposit, options.mode, options.maxUsd); +export async function runBridgeObserver(options: CliOptions): Promise { + const result = await runBridgePipeline(options); + const observation = result.observations[0]; + if (observation === undefined) { + throw new Error("no BridgeDeposit events observed"); + } + return observation; } -if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { - const options = parseBridgeArgs(process.argv.slice(2)); - const observation = await runBridgeObserver(options); - const outPath = resolve(options.outPath); +function writeJson(path: string, value: unknown): void { + const outPath = resolve(path); mkdirSync(dirname(outPath), { recursive: true }); - writeFileSync(outPath, `${JSON.stringify(observation, null, 2)}\n`); + writeFileSync(outPath, `${JSON.stringify(value, null, 2)}\n`); console.log(`Wrote ${outPath}`); } + +function artifactForSingleOrSet(values: TSingle[], setValue: TSet): TSingle | TSet { + return values.length === 1 ? values[0] as TSingle : setValue; +} + +function printRunBoundary(options: CliOptions): void { + if (options.mode === "mock") { + console.log("Bridge mode: mock fixture; no chain RPC or private key is used."); + return; + } + + const expectedChainId = expectedChainIdForMode(options.mode, options.expectedChainId); + console.log(`Bridge mode: ${options.mode}`); + console.log(`Chain id: ${expectedChainId}`); + console.log(`Lockbox: ${options.lockboxAddress}`); + console.log(`Block range: ${options.fromBlock}-${options.toBlock}`); + console.log("Broadcast: false; this observer never sends transactions."); + if (options.mode === "base-sepolia") { + console.log("Asset boundary: Base Sepolia test assets only."); + } + if (options.mode === "base-mainnet-canary") { + console.log(`Real-funds guardrail acknowledged for read-only canary. Max USD: ${options.maxUsd}`); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const options = parseBridgeArgs(process.argv.slice(2)); + printRunBoundary(options); + const result = await runBridgePipeline(options); + + writeJson( + options.outPath, + artifactForSingleOrSet(result.observations, makeObservationSet(result.observations, options.mode)), + ); + if (options.creditOutPath !== undefined) { + writeJson( + options.creditOutPath, + artifactForSingleOrSet(result.credits, makeCreditSet(result.credits)), + ); + } + if (options.handoffOutPath !== undefined) { + writeJson(options.handoffOutPath, result.handoff); + } + if (options.withdrawalOutPath !== undefined) { + writeJson( + options.withdrawalOutPath, + artifactForSingleOrSet(result.withdrawalIntents, makeWithdrawalIntentSet(result.withdrawalIntents)), + ); + } + + console.log( + `Bridge run complete: observed=${result.observations.length}, credits=${result.credits.length}, withdrawals=${result.withdrawalIntents.length}`, + ); +} diff --git a/services/bridge-relayer/test/bridge-relayer.test.ts b/services/bridge-relayer/test/bridge-relayer.test.ts index e0ebec91..e66ae1ff 100644 --- a/services/bridge-relayer/test/bridge-relayer.test.ts +++ b/services/bridge-relayer/test/bridge-relayer.test.ts @@ -1,29 +1,213 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; + +import { canonicalJson } from "../../shared/src/index.ts"; import { + BASE_SEPOLIA_CHAIN_ID, + BRIDGE_DEPOSIT_TOPIC0, + FIXED_TEST_OBSERVED_AT, + makeBridgeCredit, makeObservation, + makeRuntimeHandoff, + makeWithdrawalIntent, + parseBridgeDepositLog, parseBridgeArgs, + runBridgePipeline, validateDeposit, } from "../src/observe-base-lockbox.ts"; +const fixtureUrl = new URL("../../../fixtures/bridge/base-sepolia-mock-deposit.json", import.meta.url); + +function readSchema(name: string) { + return JSON.parse(readFileSync(new URL(`../../../schemas/flowmemory/${name}`, import.meta.url), "utf8")) as object; +} + +function bridgeAjv(): Ajv2020 { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + [ + "bridge-deposit.schema.json", + "bridge-observation.schema.json", + "bridge-observation-set.schema.json", + "bridge-credit.schema.json", + "bridge-credit-set.schema.json", + "bridge-withdrawal-intent.schema.json", + "bridge-withdrawal-intent-set.schema.json", + "bridge-runtime-handoff.schema.json", + ].forEach((name) => ajv.addSchema(readSchema(name), name)); + return ajv; +} + +function validateSchema(name: string, value: unknown): void { + const ajv = bridgeAjv(); + const validate = ajv.getSchema(`https://flowmemory.local/schemas/flowmemory/${name}`) ?? ajv.getSchema(name); + assert.ok(validate, `missing schema ${name}`); + assert.equal(validate(value), true, canonicalJson({ errors: validate.errors ?? [] })); +} + +function topic(value: bigint): `0x${string}` { + return `0x${value.toString(16).padStart(64, "0")}`; +} + +function addressTopic(address: string): `0x${string}` { + return `0x${address.slice(2).padStart(64, "0")}`; +} + +function dataWord(value: string | bigint): string { + if (typeof value === "bigint") { + return value.toString(16).padStart(64, "0"); + } + return value.slice(2).padStart(64, "0"); +} + +function sampleBridgeDepositLog() { + const sender = "0x4444444444444444444444444444444444444444"; + const token = "0x3333333333333333333333333333333333333333"; + const recipient = "0x5555555555555555555555555555555555555555555555555555555555555555"; + const metadataHash = "0x6666666666666666666666666666666666666666666666666666666666666666"; + + return { + address: "0x1111111111111111111111111111111111111111", + topics: [ + BRIDGE_DEPOSIT_TOPIC0, + "0x7777777777777777777777777777777777777777777777777777777777777777", + topic(BigInt(BASE_SEPOLIA_CHAIN_ID)), + addressTopic(sender), + ], + data: `0x${[ + dataWord(token), + dataWord(20_000_000n), + dataWord(recipient), + dataWord(7n), + dataWord(metadataHash), + ].join("")}`, + blockNumber: "0x64", + blockHash: "0x9999999999999999999999999999999999999999999999999999999999999999", + transactionHash: "0x2222222222222222222222222222222222222222222222222222222222222222", + transactionIndex: "0x2", + logIndex: "0x5", + }; +} + test("validates the committed mock bridge deposit fixture", () => { - const fixture = JSON.parse(readFileSync(new URL("../../../fixtures/bridge/base-sepolia-mock-deposit.json", import.meta.url), "utf8")); + const fixture = JSON.parse(readFileSync(fixtureUrl, "utf8")); const deposit = validateDeposit(fixture); assert.equal(deposit.schema, "flowmemory.bridge_deposit.v0"); assert.equal(deposit.sourceChainId, 84532); assert.equal(deposit.status, "observed"); + validateSchema("bridge-deposit.schema.json", deposit); }); test("builds a non-production bridge observation", () => { - const fixture = JSON.parse(readFileSync(new URL("../../../fixtures/bridge/base-sepolia-mock-deposit.json", import.meta.url), "utf8")); + const fixture = JSON.parse(readFileSync(fixtureUrl, "utf8")); const observation = makeObservation(validateDeposit(fixture), "mock"); assert.equal(observation.schema, "flowmemory.bridge_deposit_observation.v0"); assert.equal(observation.productionReady, false); assert.equal(observation.guardrails.noSecrets, true); + assert.match(observation.replayKey, /^0x[0-9a-f]{64}$/); + validateSchema("bridge-observation.schema.json", observation); +}); + +test("builds deterministic bridge credit, withdrawal intent, and runtime handoff objects", () => { + const fixture = JSON.parse(readFileSync(fixtureUrl, "utf8")); + const deposit = validateDeposit(fixture); + const observation = makeObservation(deposit, "mock"); + const credit = makeBridgeCredit(observation, "applied"); + const withdrawal = makeWithdrawalIntent(credit, deposit); + const handoff = makeRuntimeHandoff("mock", [observation], [credit], [withdrawal]); + + assert.equal(credit.status, "applied"); + assert.equal(credit.productionReady, false); + assert.equal(withdrawal.status, "requested"); + assert.equal(withdrawal.broadcast, false); + assert.deepEqual( + handoff.workbenchTimeline.map((entry) => entry.phase), + ["deposit_observed", "credit_pending", "credit_applied", "withdrawal_requested"], + ); + assert.equal(handoff.workbenchTimeline[1]?.status, "pending"); + validateSchema("bridge-credit.schema.json", credit); + validateSchema("bridge-withdrawal-intent.schema.json", withdrawal); + validateSchema("bridge-runtime-handoff.schema.json", handoff); +}); + +test("local credit smoke pipeline applies a mock credit and records test withdrawal intent", async () => { + const result = await runBridgePipeline(parseBridgeArgs([ + "--mode", + "mock", + "--fixture", + fileURLToPath(fixtureUrl), + "--apply-credit", + "--withdrawal-intent", + ])); + + assert.equal(result.observations.length, 1); + assert.equal(result.credits[0]?.status, "applied"); + assert.equal(result.withdrawalIntents[0]?.status, "requested"); + assert.equal(result.handoff.generatedAt, FIXED_TEST_OBSERVED_AT); +}); + +test("decodes BaseBridgeLockbox BridgeDeposit logs from RPC log payloads", () => { + const log = sampleBridgeDepositLog(); + const deposit = parseBridgeDepositLog(log, BASE_SEPOLIA_CHAIN_ID); + + assert.equal(deposit.depositId, log.topics[1]); + assert.equal(deposit.sourceChainId, BASE_SEPOLIA_CHAIN_ID); + assert.equal(deposit.sender, "0x4444444444444444444444444444444444444444"); + assert.equal(deposit.token, "0x3333333333333333333333333333333333333333"); + assert.equal(deposit.amount, "20000000"); + assert.equal(deposit.nonce, "7"); + assert.equal(deposit.logIndex, 5); + assert.equal(deposit.sourceBlockNumber, "100"); +}); + +test("observes Base Sepolia deposit logs through read-only RPC calls", 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); + if (body.method === "eth_chainId") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x14a34" }), { + headers: { "content-type": "application/json" }, + }); + } + if (body.method === "eth_getLogs") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: [sampleBridgeDepositLog()] }), { + 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-sepolia", + "--rpc-url", + "https://example.invalid/base-sepolia", + "--lockbox-address", + "0x1111111111111111111111111111111111111111", + "--from-block", + "100", + "--to-block", + "100", + ])); + + assert.deepEqual(calls, ["eth_chainId", "eth_getLogs"]); + assert.equal(result.observations.length, 1); + assert.equal(result.credits[0]?.status, "pending"); + assert.equal(result.handoff.productionReady, false); + } finally { + globalThis.fetch = originalFetch; + } }); test("requires explicit Base mainnet real-funds guardrails", () => {