diff --git a/.claude/commands/bridge-out.md b/.claude/commands/bridge-out.md deleted file mode 100644 index 743a35685..000000000 --- a/.claude/commands/bridge-out.md +++ /dev/null @@ -1,82 +0,0 @@ -Initiate Bridge Out via the `bridge-out` binary (payInvoice quote -> swap initialize -> init-tag). - -## Instructions - -1. Ask the user for the following parameters (skip any already provided as arguments: $ARGUMENTS): - - **rpc_url**: Node API base URL. Default: `http://localhost:8080` - - **from_addr**: Source GOAT address (used by `init-tag`) - - **to_addr**: Destination BTC address (used by `init-tag`) - - **pay_invoice_url**: Quote endpoint. Default: `https://152-32-185-32.nodes.atomiq.exchange:8443/tobtc/payInvoice?chain=GOAT` - - **amount**: Amount in pegBTC (human-readable). Example: `0.0015` - - **exact_in**: Whether quote is exact-in (`true`/`false`). Default: `true` - - **confirmation_target**: Bitcoin confirmation target. Default: `3` - - **confirmations**: Required confirmations. Default: `2` - - **token**: Token address - - **offerer**: Offerer GOAT address - - **additional_params_json**: Optional JSON object merged into payInvoice body - - **contract_address**: Optional (fallback to `GOAT_SWAP_CONTRACT_ADDRESS`) - - **max_wait_secs**: Max wait for tx receipt. Default: `60` - -2. Ensure required environment and runtime prerequisites are ready: - - `GOAT_PRIVATE_KEY` must be set (unless user passes `--goat-private-key`) - - `GOAT_CHAIN_URL` and chain config must be valid for on-chain calls - - `GOAT_SWAP_CONTRACT_ADDRESS` should be set if `--contract-address` is omitted - - Optional but recommended: use `node/.env` to manage the above variables consistently - -3. Check if the `bridge-out` binary exists at `./bin/bridge-out`. If not, run the install script to download it: - ```bash - .claude/commands/install-bitvm.sh install - ``` - To upgrade to the latest version: - ```bash - .claude/commands/install-bitvm.sh upgrade - ``` - The script auto-detects the platform (x86_64-linux / aarch64-macos), downloads from GitHub Releases, - verifies the sha256 checksum, and installs all binaries to `./bin/`. - -4. Run swap initialize (this step calls payInvoice first, then sends token approve if needed, then swap `initialize` tx): - ```bash - ./bin/bridge-out --rpc-url swap-initialize \ - --pay-invoice-url \ - --btc-address \ - --amount \ - --exact-in \ - --confirmation-target \ - --confirmations \ - --token \ - --offerer \ - --max-wait-secs - ``` - - If needed, append: - ```bash - --additional-params-json '' - --contract-address - --goat-private-key - ``` - -5. Parse output from step 4: - - `swap initialize submitted: ` - - `escrow_hash (from Initialize log): ` - Save `` for the next step. - -6. Submit bridge-out init-tag to node API: - ```bash - ./bin/bridge-out --rpc-url init-tag \ - --from-addr \ - --to-addr \ - --escrow-hash \ - --contract-address - ``` - - Alternative: derive escrow hash from tx logs directly: - ```bash - ./bin/bridge-out --rpc-url init-tag \ - --from-addr \ - --to-addr \ - --swap-init-tx-hash \ - --contract-address - ``` - -7. Optional verification (if instance id is known): - ```bash - ./bin/bridge-out --rpc-url escrow-data --instance-id - ``` diff --git a/.claude/commands/rpc-health-monitor.py b/.claude/commands/rpc-health-monitor.py index f2684c5ed..626eaa6ac 100755 --- a/.claude/commands/rpc-health-monitor.py +++ b/.claude/commands/rpc-health-monitor.py @@ -3,7 +3,7 @@ Collects and summarizes: 1) graph status counts -2) instance status counts (bridge-in/out) +2) instance status counts (bridge-in) and swap escrow status counts (bridge-out) 3) node online or offline status 4) overall service health verdict """ @@ -482,7 +482,7 @@ def summarize_once( "instance_wraps", timeout, page_size, - fixed_params={"is_bridge_in": "true"}, + fixed_params={}, ) checks.extend(in_checks) except Exception as err: # pylint: disable=broad-except @@ -493,11 +493,11 @@ def summarize_once( try: instance_out_items, instance_out_total, out_checks = fetch_pages( base_url, - "/v1/instances", - "instance_wraps", + "/v1/swaps", + "swaps", timeout, page_size, - fixed_params={"is_bridge_in": "false"}, + fixed_params={}, ) checks.extend(out_checks) except Exception as err: # pylint: disable=broad-except @@ -562,8 +562,8 @@ def summarize_once( instance_out_status_counts = count_by( instance_out_items, lambda x: ( - (x.get("instance") or {}).get("status") - if isinstance(x, dict) and isinstance(x.get("instance"), dict) + (x.get("swap") or {}).get("status") + if isinstance(x, dict) and isinstance(x.get("swap"), dict) else "MISSING_INSTANCE" ), ) diff --git a/.gitignore b/.gitignore index af69b8b73..a222e2112 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,9 @@ circuits/*/*/*.bin.in **/*.out **/*/output.data* proof-builder-rpc/*.ckpt -node/tla/states/ \ No newline at end of file +node/tla/states/ + +local_docs/ +scripts/testnet/ +scripts/devnet/ +*.DS_Store \ No newline at end of file diff --git a/circuits/operator-proof/host/src/lib.rs b/circuits/operator-proof/host/src/lib.rs index eca6e2195..841867884 100644 --- a/circuits/operator-proof/host/src/lib.rs +++ b/circuits/operator-proof/host/src/lib.rs @@ -206,7 +206,7 @@ pub async fn fetch_target_block_and_watchtower_tx( esplora_url: &str, latest_sequencer_commit_txid: &str, operator_committed_blockhash: &str, - watchtower_challenge_init_txid: &String, + watchtower_challenge_init_txid: &str, watchtower_challenge_txids: &str, watchtower_public_keys: &str, bitcoin_network: Network, diff --git a/crates/store/.sqlx/query-0771a794f73d56d8f1583f6af496009b4031922b68743ecd10f6062b680618b5.json b/crates/store/.sqlx/query-0771a794f73d56d8f1583f6af496009b4031922b68743ecd10f6062b680618b5.json deleted file mode 100644 index 8a3084eda..000000000 --- a/crates/store/.sqlx/query-0771a794f73d56d8f1583f6af496009b4031922b68743ecd10f6062b680618b5.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n UPDATE graph_raw_data\n SET raw_data = ?, updated_at = ?\n WHERE graph_id = ?\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "0771a794f73d56d8f1583f6af496009b4031922b68743ecd10f6062b680618b5" -} diff --git a/crates/store/.sqlx/query-0b91f08a5464a247f099d4b933ee65345ab3f1b9fe9f80899c0d23056772e094.json b/crates/store/.sqlx/query-0b91f08a5464a247f099d4b933ee65345ab3f1b9fe9f80899c0d23056772e094.json deleted file mode 100644 index 9c06252b4..000000000 --- a/crates/store/.sqlx/query-0b91f08a5464a247f099d4b933ee65345ab3f1b9fe9f80899c0d23056772e094.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "DELETE FROM graph_raw_data WHERE graph_id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "0b91f08a5464a247f099d4b933ee65345ab3f1b9fe9f80899c0d23056772e094" -} diff --git a/crates/store/.sqlx/query-0d833cf6fdebd05b4c34f57325c7114ea87946f233e33ddda62b376f4ca8c289.json b/crates/store/.sqlx/query-0d833cf6fdebd05b4c34f57325c7114ea87946f233e33ddda62b376f4ca8c289.json deleted file mode 100644 index a04047693..000000000 --- a/crates/store/.sqlx/query-0d833cf6fdebd05b4c34f57325c7114ea87946f233e33ddda62b376f4ca8c289.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT OR\n REPLACE INTO instance (instance_id, is_bridge_in, network, from_addr, to_addr, amount, fees, input_utxos, status, goat_tx_hash, goat_tx_height,\n user_xonly_pubkey, user_change_addr, user_refund_addr, btc_txid, pegin_confirm_txid, pegin_cancel_txid, committees_answers,\n pegin_data_tx_hash, btc_height, parameters, status_updated_at, escrow_hash, bridge_out_lock_time, post_pegin_txhash, bridge_out_amount, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,?)", - "describe": { - "columns": [], - "parameters": { - "Right": 28 - }, - "nullable": [] - }, - "hash": "0d833cf6fdebd05b4c34f57325c7114ea87946f233e33ddda62b376f4ca8c289" -} diff --git a/crates/store/.sqlx/query-139b72c815d071e58c04a9dfdfabdebf0ee70d7238941466eb7dac759326facd.json b/crates/store/.sqlx/query-139b72c815d071e58c04a9dfdfabdebf0ee70d7238941466eb7dac759326facd.json new file mode 100644 index 000000000..d01918eef --- /dev/null +++ b/crates/store/.sqlx/query-139b72c815d071e58c04a9dfdfabdebf0ee70d7238941466eb7dac759326facd.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO swap_escrow (escrow_hash, network, status, offerer_addr, claimer_addr, btc_addr, token, amount, refund_deadline, escrow_data, init_tx_hash, init_tx_height, claim_tx_hash, claim_btc_txid, refund_tx_hash, status_updated_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(escrow_hash) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Right": 18 + }, + "nullable": [] + }, + "hash": "139b72c815d071e58c04a9dfdfabdebf0ee70d7238941466eb7dac759326facd" +} diff --git a/crates/store/.sqlx/query-1beb2f77f969f063b5a0b2b2440e38ab5619517e69a0833d2bb9a0895bb89d28.json b/crates/store/.sqlx/query-1beb2f77f969f063b5a0b2b2440e38ab5619517e69a0833d2bb9a0895bb89d28.json new file mode 100644 index 000000000..fcb2694d5 --- /dev/null +++ b/crates/store/.sqlx/query-1beb2f77f969f063b5a0b2b2440e38ab5619517e69a0833d2bb9a0895bb89d28.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE swap_escrow SET status = ?, status_updated_at = ?, updated_at = ? WHERE status = ? AND refund_deadline > 0 AND refund_deadline < ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "1beb2f77f969f063b5a0b2b2440e38ab5619517e69a0833d2bb9a0895bb89d28" +} diff --git a/crates/store/.sqlx/query-1cbf508764b393270348c8a5c3e1cfb72ffc566f60ea777067a9a346d127c646.json b/crates/store/.sqlx/query-1cbf508764b393270348c8a5c3e1cfb72ffc566f60ea777067a9a346d127c646.json deleted file mode 100644 index 7c409afe0..000000000 --- a/crates/store/.sqlx/query-1cbf508764b393270348c8a5c3e1cfb72ffc566f60ea777067a9a346d127c646.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT OR REPLACE INTO graph_raw_data (graph_id, raw_data, created_at, updated_at)\n VALUES (?, ?, ?, ?)\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "1cbf508764b393270348c8a5c3e1cfb72ffc566f60ea777067a9a346d127c646" -} diff --git a/crates/store/.sqlx/query-266a306f7600058eb99a5ae3399d7790ae96754a0a5890524b3cd79325d0acff.json b/crates/store/.sqlx/query-266a306f7600058eb99a5ae3399d7790ae96754a0a5890524b3cd79325d0acff.json deleted file mode 100644 index 11d5ae9fb..000000000 --- a/crates/store/.sqlx/query-266a306f7600058eb99a5ae3399d7790ae96754a0a5890524b3cd79325d0acff.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "Update message Set state = ?, updated_at = ? WHERE business_id = ? AND state = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "266a306f7600058eb99a5ae3399d7790ae96754a0a5890524b3cd79325d0acff" -} diff --git a/crates/store/.sqlx/query-292ff9e09542309a9675b94debe3e6448e5f68b063790542db1c15d0be2a3720.json b/crates/store/.sqlx/query-292ff9e09542309a9675b94debe3e6448e5f68b063790542db1c15d0be2a3720.json deleted file mode 100644 index 7a335a19b..000000000 --- a/crates/store/.sqlx/query-292ff9e09542309a9675b94debe3e6448e5f68b063790542db1c15d0be2a3720.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE graph\n SET status = ?,\n status_updated_at = ?,\n updated_at = ?\n WHERE instance_id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "292ff9e09542309a9675b94debe3e6448e5f68b063790542db1c15d0be2a3720" -} diff --git a/crates/store/.sqlx/query-320fa33b8cca81e01065c39a357eb85dcba4bae69851f645309213da033b0473.json b/crates/store/.sqlx/query-320fa33b8cca81e01065c39a357eb85dcba4bae69851f645309213da033b0473.json deleted file mode 100644 index 9967a4a64..000000000 --- a/crates/store/.sqlx/query-320fa33b8cca81e01065c39a357eb85dcba4bae69851f645309213da033b0473.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO message (message_id, business_id, from_peer, actor, msg_type, content, state, message_version, lock_time_until, weight, updated_at, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(message_id) DO UPDATE SET business_id = excluded.business_id,\n from_peer = excluded.from_peer,\n actor = excluded.actor,\n msg_type = excluded.msg_type,\n content = excluded.content,\n state = excluded.state,\n message_version = message.message_version + 1,\n lock_time_until = excluded.lock_time_until,\n weight = excluded.weight,\n updated_at = excluded.updated_at", - "describe": { - "columns": [], - "parameters": { - "Right": 12 - }, - "nullable": [] - }, - "hash": "320fa33b8cca81e01065c39a357eb85dcba4bae69851f645309213da033b0473" -} diff --git a/crates/store/.sqlx/query-372f1a46cbff240ee0146e23597d5926ec488b778d078cf4eded3075dfeff11f.json b/crates/store/.sqlx/query-372f1a46cbff240ee0146e23597d5926ec488b778d078cf4eded3075dfeff11f.json deleted file mode 100644 index 237c755c8..000000000 --- a/crates/store/.sqlx/query-372f1a46cbff240ee0146e23597d5926ec488b778d078cf4eded3075dfeff11f.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT message_id,\n business_id AS \"business_id:Uuid\",\n from_peer,\n actor,\n msg_type,\n content,\n message_version,\n state,\n weight,\n lock_time_until\n FROM message\n WHERE business_id = ? AND msg_type = ?", - "describe": { - "columns": [ - { - "name": "message_id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "business_id:Uuid", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "from_peer", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "actor", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "msg_type", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "content", - "ordinal": 5, - "type_info": "Blob" - }, - { - "name": "message_version", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "state", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "weight", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "lock_time_until", - "ordinal": 9, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "372f1a46cbff240ee0146e23597d5926ec488b778d078cf4eded3075dfeff11f" -} diff --git a/crates/store/.sqlx/query-42fb787e5cae022245ddb43b569e74bcef7fb613b18ac7663319c8f821f9296d.json b/crates/store/.sqlx/query-42fb787e5cae022245ddb43b569e74bcef7fb613b18ac7663319c8f821f9296d.json deleted file mode 100644 index 61d0aabc2..000000000 --- a/crates/store/.sqlx/query-42fb787e5cae022245ddb43b569e74bcef7fb613b18ac7663319c8f821f9296d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "Update message Set state = ?, updated_at = ? WHERE message_id = ? AND message_version = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "42fb787e5cae022245ddb43b569e74bcef7fb613b18ac7663319c8f821f9296d" -} diff --git a/crates/store/.sqlx/query-5c56418dfb5c977395eb0fe75bdc9b5feeffbf5abe927b474186070c1624dd13.json b/crates/store/.sqlx/query-5c56418dfb5c977395eb0fe75bdc9b5feeffbf5abe927b474186070c1624dd13.json deleted file mode 100644 index f7136fd92..000000000 --- a/crates/store/.sqlx/query-5c56418dfb5c977395eb0fe75bdc9b5feeffbf5abe927b474186070c1624dd13.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE graph\n SET status = ?,\n status_updated_at = ?,\n updated_at = ?\n WHERE instance_id = ?\n AND graph_id != ?", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "5c56418dfb5c977395eb0fe75bdc9b5feeffbf5abe927b474186070c1624dd13" -} diff --git a/crates/store/.sqlx/query-8c69b8b08d7321de8160a9a61a66ec87546965ff35877dff9d8cc394a4ac89e1.json b/crates/store/.sqlx/query-8c69b8b08d7321de8160a9a61a66ec87546965ff35877dff9d8cc394a4ac89e1.json new file mode 100644 index 000000000..006fde062 --- /dev/null +++ b/crates/store/.sqlx/query-8c69b8b08d7321de8160a9a61a66ec87546965ff35877dff9d8cc394a4ac89e1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR\n REPLACE INTO instance (instance_id, network, from_addr, to_addr, amount, fees, input_utxos, status, goat_tx_hash, goat_tx_height,\n user_xonly_pubkey, user_change_addr, user_refund_addr, btc_txid, pegin_confirm_txid, pegin_cancel_txid, committees_answers,\n pegin_data_tx_hash, btc_height, parameters, status_updated_at, post_pegin_txhash, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 24 + }, + "nullable": [] + }, + "hash": "8c69b8b08d7321de8160a9a61a66ec87546965ff35877dff9d8cc394a4ac89e1" +} diff --git a/crates/store/.sqlx/query-bb8e6031d9255b7a1195aadadfc9b70b205939583c6cacd012fcc2cede7cc6aa.json b/crates/store/.sqlx/query-bb8e6031d9255b7a1195aadadfc9b70b205939583c6cacd012fcc2cede7cc6aa.json deleted file mode 100644 index cbbd379b9..000000000 --- a/crates/store/.sqlx/query-bb8e6031d9255b7a1195aadadfc9b70b205939583c6cacd012fcc2cede7cc6aa.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT message_id,\n business_id AS \"business_id:Uuid\",\n from_peer,\n actor,\n msg_type,\n content,\n message_version,\n state,\n weight,\n lock_time_until\n FROM message\n WHERE message_id = ?", - "describe": { - "columns": [ - { - "name": "message_id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "business_id:Uuid", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "from_peer", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "actor", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "msg_type", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "content", - "ordinal": 5, - "type_info": "Blob" - }, - { - "name": "message_version", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "state", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "weight", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "lock_time_until", - "ordinal": 9, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "bb8e6031d9255b7a1195aadadfc9b70b205939583c6cacd012fcc2cede7cc6aa" -} diff --git a/crates/store/.sqlx/query-c9f0cf6af20db541f198fcbb8f10ba7b11ebd7601ca2468461db518db2d5f494.json b/crates/store/.sqlx/query-c9f0cf6af20db541f198fcbb8f10ba7b11ebd7601ca2468461db518db2d5f494.json deleted file mode 100644 index 36c667c53..000000000 --- a/crates/store/.sqlx/query-c9f0cf6af20db541f198fcbb8f10ba7b11ebd7601ca2468461db518db2d5f494.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "Update message Set state = ?, updated_at = ? WHERE business_id = ? AND msg_type = ? AND state = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "c9f0cf6af20db541f198fcbb8f10ba7b11ebd7601ca2468461db518db2d5f494" -} diff --git a/crates/store/.sqlx/query-cc6525abb198055f787dd0bfcc0d0095ecb645fdfd91d202ce6aba0220349737.json b/crates/store/.sqlx/query-cc6525abb198055f787dd0bfcc0d0095ecb645fdfd91d202ce6aba0220349737.json deleted file mode 100644 index f9b16ce88..000000000 --- a/crates/store/.sqlx/query-cc6525abb198055f787dd0bfcc0d0095ecb645fdfd91d202ce6aba0220349737.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT OR\n REPLACE INTO graph (graph_id, instance_id, kickoff_index, from_addr, to_addr, amount, challenge_amount,\n status, sub_status, operator_pubkey, cur_prekickoff_txid, next_prekickoff, force_skip_kickoff_txid,\n quick_challenge_txid, challenge_incomplete_kickoff_txid, pegin_txid, kickoff_txid, take1_txid,\n challenge_txid, take2_txid, watchtower_challenge_init_txid, operator_assert_txid, verifier_assert_txids, disprove_txids,\n watchtower_challenge_timeout_txids, operator_challenge_nack_txids, operator_commit_timeout_txid,\n init_withdraw_tx_hash,\n bridge_out_start_at, status_updated_at, proceed_withdraw_height, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 33 - }, - "nullable": [] - }, - "hash": "cc6525abb198055f787dd0bfcc0d0095ecb645fdfd91d202ce6aba0220349737" -} diff --git a/crates/store/.sqlx/query-f182e4c5bd4f23846671dc7a9db745dbb8f8bed0b1f33d56e8a1b6c4028c9617.json b/crates/store/.sqlx/query-f182e4c5bd4f23846671dc7a9db745dbb8f8bed0b1f33d56e8a1b6c4028c9617.json deleted file mode 100644 index a682875e3..000000000 --- a/crates/store/.sqlx/query-f182e4c5bd4f23846671dc7a9db745dbb8f8bed0b1f33d56e8a1b6c4028c9617.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT message_id,\n business_id AS \"business_id:Uuid\",\n from_peer,\n actor,\n msg_type,\n content,\n message_version,\n state,\n weight,\n lock_time_until\n FROM message\n WHERE state = ?\n AND weight >= ?\n AND lock_time_until <= ?\n AND updated_at >= ?\n ORDER BY created_at ASC\n LIMIT ? OFFSET ?", - "describe": { - "columns": [ - { - "name": "message_id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "business_id:Uuid", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "from_peer", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "actor", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "msg_type", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "content", - "ordinal": 5, - "type_info": "Blob" - }, - { - "name": "message_version", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "state", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "weight", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "lock_time_until", - "ordinal": 9, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 6 - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "f182e4c5bd4f23846671dc7a9db745dbb8f8bed0b1f33d56e8a1b6c4028c9617" -} diff --git a/crates/store/migrations/20250814115805_create_instance_table.sql b/crates/store/migrations/20250814115805_create_instance_table.sql index f283a98ec..c563492b2 100644 --- a/crates/store/migrations/20250814115805_create_instance_table.sql +++ b/crates/store/migrations/20250814115805_create_instance_table.sql @@ -3,7 +3,6 @@ DROP TABLE IF EXISTS `instance`; CREATE TABLE instance ( `instance_id` TEXT NOT NULL DEFAULT '', - `is_bridge_in` BOOL NOT NULL DEFAULT 1, `network` TEXT NOT NULL DEFAULT 'test', `from_addr` TEXT NOT NULL DEFAULT '', `to_addr` TEXT NOT NULL DEFAULT '', @@ -23,9 +22,6 @@ CREATE TABLE instance `pegin_data_tx_hash` TEXT NOT NULL DEFAULT '', `btc_height` BIGINT UNSIGNED NOT NULL DEFAULT 0, `parameters` TEXT, - `escrow_hash` TEXT, - `bridge_out_amount` TEXT NOT NULL DEFAULT '0', - `bridge_out_lock_time` BIGINT NOT NULL DEFAULT 0, `post_pegin_txhash` TEXT, `status_updated_at` BIGINT NOT NULL DEFAULT 0, `created_at` BIGINT NOT NULL DEFAULT 0, diff --git a/crates/store/migrations/20260827120000_add_p2p_outbox_retry_window.sql b/crates/store/migrations/20260827120000_add_p2p_outbox_retry_window.sql new file mode 100644 index 000000000..7f027539f --- /dev/null +++ b/crates/store/migrations/20260827120000_add_p2p_outbox_retry_window.sql @@ -0,0 +1,6 @@ +ALTER TABLE p2p_outbox ADD COLUMN retry_until BIGINT NOT NULL DEFAULT 0; +ALTER TABLE p2p_outbox ADD COLUMN retry_interval_secs BIGINT NOT NULL DEFAULT 0; +ALTER TABLE p2p_outbox ADD COLUMN ack_peer_id TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_p2p_outbox_retry_window + ON p2p_outbox (state, retry_until, next_retry_at); diff --git a/crates/store/migrations/20260827130000_create_swap_escrow_table.sql b/crates/store/migrations/20260827130000_create_swap_escrow_table.sql new file mode 100644 index 000000000..812d611c9 --- /dev/null +++ b/crates/store/migrations/20260827130000_create_swap_escrow_table.sql @@ -0,0 +1,25 @@ +-- Swap-based bridge-out escrows, keyed by the on-chain escrow hash. +-- Fully independent of the BitVM `instance`/`graph` flow. +DROP TABLE IF EXISTS `swap_escrow`; +CREATE TABLE swap_escrow +( + `escrow_hash` TEXT NOT NULL, -- 0x-prefixed 32-byte hex, lowercase + `network` TEXT NOT NULL DEFAULT '', + `status` TEXT NOT NULL DEFAULT '', -- SwapEscrowStatus + `offerer_addr` TEXT NOT NULL DEFAULT '', -- GOAT account funding the escrow + `claimer_addr` TEXT NOT NULL DEFAULT '', -- GOAT account entitled to claim + `btc_addr` TEXT NOT NULL DEFAULT '', -- BTC address receiving the payout + `token` TEXT NOT NULL DEFAULT '', -- escrow token contract address + `amount` TEXT NOT NULL DEFAULT '0', -- escrow amount, U256 decimal string + `refund_deadline` BIGINT NOT NULL DEFAULT 0, -- unix secs after which refund is possible + `escrow_data` TEXT, -- hex abi-encoded EscrowData from Initialize tx + `init_tx_hash` TEXT NOT NULL DEFAULT '', -- swap Initialize GOAT tx + `init_tx_height` BIGINT NOT NULL DEFAULT 0, + `claim_tx_hash` TEXT NOT NULL DEFAULT '', -- swap Claim GOAT tx + `claim_btc_txid` TEXT, -- BTC payout tx committed by the claim + `refund_tx_hash` TEXT NOT NULL DEFAULT '', -- swap Refund GOAT tx + `status_updated_at` BIGINT NOT NULL DEFAULT 0, + `created_at` BIGINT NOT NULL DEFAULT 0, + `updated_at` BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (`escrow_hash`) +); diff --git a/crates/store/src/localdb.rs b/crates/store/src/localdb.rs index 73d25120b..8660fcd52 100644 --- a/crates/store/src/localdb.rs +++ b/crates/store/src/localdb.rs @@ -5,8 +5,8 @@ use crate::{ LongRunningTaskProof, Message, MessageDebugOverview, MessageDebugReason, MetricsStateCount, Node, NodeAlertMetricsSnapshot, NodesOverview, OperatorProof, P2pInboxMessage, P2pOutboxMessage, PeginGraphProcessData, PeginInstanceProcessData, PendingGraphInit, - SequencerSetHashChange, SequencerSetScanState, SerializableTxid, WatchContract, - WatchtowerProof, + SequencerSetHashChange, SequencerSetScanState, SerializableTxid, SwapEscrow, SwapEscrowStatus, + WatchContract, WatchtowerProof, }; use indexmap::IndexMap; @@ -69,6 +69,9 @@ fn p2p_outbox_message_from_row(row: &SqliteRow) -> Result, - pub escrow_hash: Option, pub statuses: Vec, pub earliest_updated: Option, pub pegin_request_height_threshold: Option, @@ -176,11 +177,6 @@ pub struct InstanceQuery { } impl InstanceQuery { - pub fn with_is_bridge_in(mut self, is_bridge_in: bool) -> Self { - self.is_bridge_in = is_bridge_in; - self - } - pub fn with_from_addr(mut self, from_addr: String) -> Self { self.from_addr = Some(from_addr); self @@ -201,11 +197,6 @@ impl InstanceQuery { self } - pub fn with_escrow_hash(mut self, escrow_hash: String) -> Self { - self.escrow_hash = Some(escrow_hash); - self - } - pub fn with_earliest_updated(mut self, earliest_updated: i64) -> Self { self.earliest_updated = Some(earliest_updated); self @@ -244,16 +235,10 @@ impl InstanceQuery { pub fn get_query_builder(&self, base_sql: &str) -> QueryBuilder { let mut query_builder = QueryBuilder::new(base_sql); - query_builder.and_where("is_bridge_in = ?", Some(QueryParam::Bool(self.is_bridge_in))); - if let Some(from_addr) = &self.from_addr { query_builder.and_where("from_addr = ?", Some(QueryParam::Text(from_addr.clone()))); } - if let Some(escrow_hash) = &self.escrow_hash { - query_builder.and_where("escrow_hash = ?", Some(QueryParam::Text(escrow_hash.clone()))); - } - if !self.statuses.is_empty() { query_builder.and_where_in("status", &self.statuses, false); } @@ -283,8 +268,7 @@ impl InstanceQuery { /// Provides a more elegant way to specify which instance fields to update #[derive(Debug, Clone)] pub struct InstanceUpdate { - pub instance_id: Option, - pub escrow_hash: Option, + pub instance_id: Uuid, pub from_addr: Option, pub to_addr: Option, pub btc_txid: Option, @@ -294,22 +278,14 @@ pub struct InstanceUpdate { pub post_pegin_txhash: Option, pub btc_height: Option, pub committees_answers: Option>>, - pub bridge_out_lock_time: Option, - pub bridge_out_amount: Option, - pub goat_tx_hash: Option, - pub goat_tx_height: Option, - pub user_change_addr: Option, - pub user_refund_addr: Option, pub only_if_status_in: Option>, - pub only_if_is_bridge_in: Option, - pub only_if_goat_tx_hash: Option, } impl InstanceUpdate { - fn empty() -> Self { + /// Create new update parameters + pub fn new_with_instance_id(instance_id: Uuid) -> Self { Self { - instance_id: None, - escrow_hash: None, + instance_id, from_addr: None, to_addr: None, btc_txid: None, @@ -319,26 +295,10 @@ impl InstanceUpdate { post_pegin_txhash: None, btc_height: None, committees_answers: None, - bridge_out_lock_time: None, - bridge_out_amount: None, - goat_tx_hash: None, - goat_tx_height: None, - user_change_addr: None, - user_refund_addr: None, only_if_status_in: None, - only_if_is_bridge_in: None, - only_if_goat_tx_hash: None, } } - /// Create new update parameters - pub fn new_with_instance_id(instance_id: Uuid) -> Self { - Self { instance_id: Some(instance_id), ..Self::empty() } - } - pub fn new_with_escrow_hash(escrow_hash: String) -> Self { - Self { escrow_hash: Some(escrow_hash), ..Self::empty() } - } - /// Set from_addr pub fn with_from_addr(mut self, from_addr: String) -> Self { self.from_addr = Some(from_addr); @@ -395,37 +355,6 @@ impl InstanceUpdate { self } - /// Set bridge out lock time - pub fn with_bridge_out_lock_time(mut self, bridge_out_lock_time: i64) -> Self { - self.bridge_out_lock_time = Some(bridge_out_lock_time); - self - } - - pub fn with_bridge_out_amount(mut self, bridge_out_amount: String) -> Self { - self.bridge_out_amount = Some(bridge_out_amount); - self - } - - pub fn with_goat_tx_hash(mut self, goat_tx_hash: String) -> Self { - self.goat_tx_hash = Some(goat_tx_hash); - self - } - - pub fn with_goat_tx_height(mut self, goat_tx_height: i64) -> Self { - self.goat_tx_height = Some(goat_tx_height); - self - } - - pub fn with_user_change_addr(mut self, user_change_addr: String) -> Self { - self.user_change_addr = Some(user_change_addr); - self - } - - pub fn with_user_refund_addr(mut self, user_refund_addr: String) -> Self { - self.user_refund_addr = Some(user_refund_addr); - self - } - /// Apply this update only while the instance is still in one of the /// expected states. The condition is folded into the UPDATE statement. pub fn with_only_if_status_in(mut self, statuses: Vec) -> Self { @@ -433,23 +362,9 @@ impl InstanceUpdate { self } - /// Apply this update only to the expected bridge direction. - pub fn with_only_if_is_bridge_in(mut self, is_bridge_in: bool) -> Self { - self.only_if_is_bridge_in = Some(is_bridge_in); - self - } - - /// Apply this update only when the existing Goat transaction hash is the - /// expected value. Used to make swap initialization idempotent. - pub fn with_only_if_goat_tx_hash(mut self, goat_tx_hash: String) -> Self { - self.only_if_goat_tx_hash = Some(goat_tx_hash); - self - } - /// Check if any fields need to be updated pub fn has_updates(&self) -> bool { - self.escrow_hash.is_some() - || self.from_addr.is_some() + self.from_addr.is_some() || self.to_addr.is_some() || self.btc_txid.is_some() || self.status.is_some() @@ -458,12 +373,6 @@ impl InstanceUpdate { || self.post_pegin_txhash.is_some() || self.btc_height.is_some() || self.committees_answers.is_some() - || self.bridge_out_lock_time.is_some() - || self.bridge_out_amount.is_some() - || self.goat_tx_hash.is_some() - || self.goat_tx_height.is_some() - || self.user_change_addr.is_some() - || self.user_refund_addr.is_some() } pub fn get_query_builder(&self, base_sql: &str) -> QueryBuilder { @@ -503,31 +412,6 @@ impl InstanceUpdate { query_builder.set_field("btc_txid", QueryParam::BTCTxid(btc_txid.clone())); } - if let Some(bridge_out_lock_time) = self.bridge_out_lock_time { - query_builder.set_field("bridge_out_lock_time", QueryParam::Int(bridge_out_lock_time)); - } - - if let Some(ref bridge_out_amount) = self.bridge_out_amount { - query_builder - .set_field("bridge_out_amount", QueryParam::Text(bridge_out_amount.clone())); - } - - if let Some(ref goat_tx_hash) = self.goat_tx_hash { - query_builder.set_field("goat_tx_hash", QueryParam::Text(goat_tx_hash.clone())); - } - - if let Some(goat_tx_height) = self.goat_tx_height { - query_builder.set_field("goat_tx_height", QueryParam::Int(goat_tx_height)); - } - - if let Some(ref user_change_addr) = self.user_change_addr { - query_builder.set_field("user_change_addr", QueryParam::Text(user_change_addr.clone())); - } - - if let Some(ref user_refund_addr) = self.user_refund_addr { - query_builder.set_field("user_refund_addr", QueryParam::Text(user_refund_addr.clone())); - } - if let Some(ref committees_answers) = self.committees_answers { let committees_answers = serde_json::to_string(committees_answers) .expect("IndexMap> serialization is infallible"); @@ -539,16 +423,10 @@ impl InstanceUpdate { query_builder.set_field("updated_at", QueryParam::Int(current_time)); // Add WHERE clause - if let Some(ref instance_id) = self.instance_id { - query_builder.and_where( - "hex(instance_id) = ? COLLATE NOCASE ", - Some(QueryParam::Text(hex::encode(instance_id))), - ); - } - if let Some(ref escrow_hash) = self.escrow_hash { - query_builder - .and_where("escrow_hash = ? ", Some(QueryParam::Text(escrow_hash.clone()))); - } + query_builder.and_where( + "hex(instance_id) = ? COLLATE NOCASE ", + Some(QueryParam::Text(hex::encode(self.instance_id))), + ); if let Some(ref statuses) = self.only_if_status_in { if statuses.is_empty() { @@ -560,15 +438,200 @@ impl InstanceUpdate { } } - if let Some(is_bridge_in) = self.only_if_is_bridge_in { - query_builder.and_where("is_bridge_in = ?", Some(QueryParam::Bool(is_bridge_in))); + query_builder + } +} + +/// Filter parameters for listing swap escrows. +#[derive(Clone, Debug, Default)] +pub struct SwapEscrowQuery { + pub offerer_addr: Option, + pub statuses: Vec, + pub order: Option, + pub offset: Option, + pub limit: Option, +} + +impl SwapEscrowQuery { + pub fn with_offerer_addr(mut self, offerer_addr: String) -> Self { + self.offerer_addr = Some(offerer_addr); + self + } + + pub fn with_status(mut self, status: String) -> Self { + self.statuses.push(status); + self + } + + pub fn with_order(mut self, order: String) -> Self { + self.order = Some(order); + self + } + + pub fn with_pagination(mut self, offset: u32, limit: u32) -> Self { + self.offset = Some(offset); + self.limit = Some(limit); + self + } + + pub fn get_query_builder(&self, base_sql: &str) -> QueryBuilder { + let mut query_builder = QueryBuilder::new(base_sql); + if let Some(offerer_addr) = &self.offerer_addr { + query_builder + .and_where("offerer_addr = ?", Some(QueryParam::Text(offerer_addr.clone()))); + } + if !self.statuses.is_empty() { + query_builder.and_where_in("status", &self.statuses, false); } + if let Some(order) = &self.order { + query_builder.apply_order(order); + } + query_builder.apply_pagination(self.limit, self.offset); + query_builder + } +} + +/// Swap escrow field update parameters, keyed by escrow hash. +#[derive(Debug, Clone, Default)] +pub struct SwapEscrowUpdate { + pub escrow_hash: String, + pub status: Option, + pub claimer_addr: Option, + pub btc_addr: Option, + pub token: Option, + pub amount: Option, + pub refund_deadline: Option, + pub escrow_data: Option, + pub init_tx_hash: Option, + pub init_tx_height: Option, + pub claim_tx_hash: Option, + pub claim_btc_txid: Option, + pub refund_tx_hash: Option, + pub only_if_status_in: Option>, +} - if let Some(ref goat_tx_hash) = self.only_if_goat_tx_hash { +impl SwapEscrowUpdate { + pub fn new(escrow_hash: String) -> Self { + Self { escrow_hash, ..Self::default() } + } + + pub fn with_status(mut self, status: String) -> Self { + self.status = Some(status); + self + } + + pub fn with_claimer_addr(mut self, claimer_addr: String) -> Self { + self.claimer_addr = Some(claimer_addr); + self + } + + pub fn with_btc_addr(mut self, btc_addr: String) -> Self { + self.btc_addr = Some(btc_addr); + self + } + + pub fn with_token(mut self, token: String) -> Self { + self.token = Some(token); + self + } + + pub fn with_amount(mut self, amount: String) -> Self { + self.amount = Some(amount); + self + } + + pub fn with_refund_deadline(mut self, refund_deadline: i64) -> Self { + self.refund_deadline = Some(refund_deadline); + self + } + + pub fn with_escrow_data(mut self, escrow_data: String) -> Self { + self.escrow_data = Some(escrow_data); + self + } + + pub fn with_init_tx(mut self, tx_hash: String, tx_height: i64) -> Self { + self.init_tx_hash = Some(tx_hash); + self.init_tx_height = Some(tx_height); + self + } + + pub fn with_claim_tx_hash(mut self, tx_hash: String) -> Self { + self.claim_tx_hash = Some(tx_hash); + self + } + + pub fn with_claim_btc_txid(mut self, txid: SerializableTxid) -> Self { + self.claim_btc_txid = Some(txid); + self + } + + pub fn with_refund_tx_hash(mut self, tx_hash: String) -> Self { + self.refund_tx_hash = Some(tx_hash); + self + } + + /// Apply this update only while the escrow is still in one of the + /// expected states. The condition is folded into the UPDATE statement. + pub fn with_only_if_status_in(mut self, statuses: Vec) -> Self { + self.only_if_status_in = Some(statuses); + self + } + + pub fn get_query_builder(&self, base_sql: &str) -> QueryBuilder { + let mut query_builder = QueryBuilder::update(base_sql); + if let Some(ref status) = self.status { + query_builder.set_field("status", QueryParam::Text(status.clone())); query_builder - .and_where("goat_tx_hash = ?", Some(QueryParam::Text(goat_tx_hash.clone()))); + .set_field("status_updated_at", QueryParam::Int(get_current_timestamp_secs())); + } + if let Some(ref claimer_addr) = self.claimer_addr { + query_builder.set_field("claimer_addr", QueryParam::Text(claimer_addr.clone())); + } + if let Some(ref btc_addr) = self.btc_addr { + query_builder.set_field("btc_addr", QueryParam::Text(btc_addr.clone())); + } + if let Some(ref token) = self.token { + query_builder.set_field("token", QueryParam::Text(token.clone())); + } + if let Some(ref amount) = self.amount { + query_builder.set_field("amount", QueryParam::Text(amount.clone())); + } + if let Some(refund_deadline) = self.refund_deadline { + query_builder.set_field("refund_deadline", QueryParam::Int(refund_deadline)); + } + if let Some(ref escrow_data) = self.escrow_data { + query_builder.set_field("escrow_data", QueryParam::Text(escrow_data.clone())); + } + if let Some(ref init_tx_hash) = self.init_tx_hash { + query_builder.set_field("init_tx_hash", QueryParam::Text(init_tx_hash.clone())); + } + if let Some(init_tx_height) = self.init_tx_height { + query_builder.set_field("init_tx_height", QueryParam::Int(init_tx_height)); + } + if let Some(ref claim_tx_hash) = self.claim_tx_hash { + query_builder.set_field("claim_tx_hash", QueryParam::Text(claim_tx_hash.clone())); + } + if let Some(ref claim_btc_txid) = self.claim_btc_txid { + query_builder.set_field("claim_btc_txid", QueryParam::BTCTxid(claim_btc_txid.clone())); + } + if let Some(ref refund_tx_hash) = self.refund_tx_hash { + query_builder.set_field("refund_tx_hash", QueryParam::Text(refund_tx_hash.clone())); } + query_builder.set_field("updated_at", QueryParam::Int(get_current_timestamp_secs())); + + query_builder + .and_where("escrow_hash = ?", Some(QueryParam::Text(self.escrow_hash.clone()))); + if let Some(ref statuses) = self.only_if_status_in { + if statuses.is_empty() { + // An empty allow-list must reject the update rather than + // silently dropping the compare-and-swap guard. + query_builder.and_where("1 = 0", None); + } else { + query_builder.and_where_in("status", statuses, false); + } + } query_builder } } @@ -901,13 +964,22 @@ impl<'a> StorageProcessor<'a> { let counts = sqlx::query_as::<_, MetricsStateCount>( r#" SELECT - CASE WHEN is_bridge_in THEN 'instance_bridge_in' ELSE 'instance_bridge_out' END AS category, + 'instance' AS category, status AS state, COUNT(*) AS count, MIN(created_at) AS oldest_created_at, NULL AS last_success_at FROM instance - GROUP BY is_bridge_in, status + GROUP BY status + UNION ALL + SELECT + 'swap_escrow' AS category, + status AS state, + COUNT(*) AS count, + MIN(created_at) AS oldest_created_at, + NULL AS last_success_at + FROM swap_escrow + GROUP BY status UNION ALL SELECT 'graph' AS category, @@ -942,30 +1014,31 @@ impl<'a> StorageProcessor<'a> { let snapshot = sqlx::query_as::<_, NodeAlertMetricsSnapshot>( r#" SELECT - MIN(CASE - WHEN is_bridge_in = 1 - AND status NOT IN ( + ( + SELECT MIN(status_updated_at) + FROM instance + WHERE status NOT IN ( 'RelayerL2Minted', 'PresignedFailed', 'RelayerL2MintedFailed', 'Timeout', 'UserCanceled', 'NoEnoughCommitteesAnswered', 'UserDiscarded', 'Failed', 'Success', 'Canceled' - ) - THEN status_updated_at - END) AS pegin_oldest_active_status_updated_at, - MIN(CASE - WHEN is_bridge_in = 1 AND status = 'UserInited' - THEN status_updated_at - END) AS pegin_oldest_committee_wait_status_updated_at, - MIN(CASE - WHEN is_bridge_in = 0 AND status NOT IN ('Claim', 'Timeout', 'Refund') - THEN status_updated_at - END) AS pegout_oldest_active_status_updated_at, + ) + ) AS pegin_oldest_active_status_updated_at, + ( + SELECT MIN(status_updated_at) + FROM instance + WHERE status = 'UserInited' + ) AS pegin_oldest_committee_wait_status_updated_at, + ( + SELECT MIN(status_updated_at) + FROM swap_escrow + WHERE status NOT IN ('Claim', 'Timeout', 'Refund') + ) AS pegout_oldest_active_status_updated_at, ( SELECT available_peg_btc FROM node WHERE peer_id = ? AND actor = 'Operator' LIMIT 1 ) AS operator_available_pegbtc - FROM instance "#, ) .bind(local_peer_id) @@ -1052,12 +1125,11 @@ impl<'a> StorageProcessor<'a> { let committees_answers_json = serde_json::to_string(&instance.committees_answers)?; let res = sqlx::query!( "INSERT OR - REPLACE INTO instance (instance_id, is_bridge_in, network, from_addr, to_addr, amount, fees, input_utxos, status, goat_tx_hash, goat_tx_height, + REPLACE INTO instance (instance_id, network, from_addr, to_addr, amount, fees, input_utxos, status, goat_tx_hash, goat_tx_height, user_xonly_pubkey, user_change_addr, user_refund_addr, btc_txid, pegin_confirm_txid, pegin_cancel_txid, committees_answers, - pegin_data_tx_hash, btc_height, parameters, status_updated_at, escrow_hash, bridge_out_lock_time, post_pegin_txhash, bridge_out_amount, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,?)", + pegin_data_tx_hash, btc_height, parameters, status_updated_at, post_pegin_txhash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", instance.instance_id, - instance.is_bridge_in, instance.network, instance.from_addr, instance.to_addr, @@ -1078,10 +1150,7 @@ impl<'a> StorageProcessor<'a> { instance.btc_height, instance.parameters, instance.status_updated_at, - instance.escrow_hash, - instance.bridge_out_lock_time, instance.post_pegin_txhash, - instance.bridge_out_amount, instance.created_at, instance.updated_at ) @@ -1090,56 +1159,6 @@ impl<'a> StorageProcessor<'a> { Ok(res.rows_affected() > 0) } - /// Insert an instance only when its ID is not already present. - /// - /// Creation paths that race with status transitions must use this instead - /// of `upsert_instance`, whose `INSERT OR REPLACE` semantics can restore - /// a stale full row over a newer terminal status. - pub async fn insert_instance_if_absent(&mut self, instance: &Instance) -> anyhow::Result { - let committees_answers_json = serde_json::to_string(&instance.committees_answers)?; - let res = sqlx::query( - "INSERT INTO instance \ - (instance_id, is_bridge_in, network, from_addr, to_addr, amount, fees, input_utxos, \ - status, goat_tx_hash, goat_tx_height, user_xonly_pubkey, user_change_addr, \ - user_refund_addr, btc_txid, pegin_confirm_txid, pegin_cancel_txid, committees_answers, \ - pegin_data_tx_hash, btc_height, parameters, status_updated_at, escrow_hash, \ - bridge_out_lock_time, post_pegin_txhash, bridge_out_amount, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ - ON CONFLICT(instance_id) DO NOTHING", - ) - .bind(instance.instance_id) - .bind(instance.is_bridge_in) - .bind(&instance.network) - .bind(&instance.from_addr) - .bind(&instance.to_addr) - .bind(instance.amount) - .bind(instance.fees) - .bind(&instance.input_utxos) - .bind(&instance.status) - .bind(&instance.goat_tx_hash) - .bind(instance.goat_tx_height) - .bind(instance.user_xonly_pubkey) - .bind(&instance.user_change_addr) - .bind(&instance.user_refund_addr) - .bind(&instance.btc_txid) - .bind(&instance.pegin_confirm_txid) - .bind(&instance.pegin_cancel_txid) - .bind(committees_answers_json) - .bind(&instance.pegin_data_tx_hash) - .bind(instance.btc_height) - .bind(&instance.parameters) - .bind(instance.status_updated_at) - .bind(&instance.escrow_hash) - .bind(instance.bridge_out_lock_time) - .bind(&instance.post_pegin_txhash) - .bind(&instance.bridge_out_amount) - .bind(instance.created_at) - .bind(instance.updated_at) - .execute(self.conn()) - .await?; - Ok(res.rows_affected() > 0) - } - /// Find a single instance by its ID /// /// Retrieves an instance from the database using its unique instance_id. @@ -1216,6 +1235,111 @@ impl<'a> StorageProcessor<'a> { } } + /// Insert a swap escrow only when its escrow hash is not already present. + /// + /// The chain-event watcher is the sole writer for Initialize records; + /// the escrow-hash primary key makes event replays idempotent. + pub async fn insert_swap_escrow_if_absent( + &mut self, + escrow: &SwapEscrow, + ) -> anyhow::Result { + let res = sqlx::query!( + "INSERT INTO swap_escrow \ + (escrow_hash, network, status, offerer_addr, claimer_addr, btc_addr, token, amount, \ + refund_deadline, escrow_data, init_tx_hash, init_tx_height, claim_tx_hash, \ + claim_btc_txid, refund_tx_hash, status_updated_at, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(escrow_hash) DO NOTHING", + escrow.escrow_hash, + escrow.network, + escrow.status, + escrow.offerer_addr, + escrow.claimer_addr, + escrow.btc_addr, + escrow.token, + escrow.amount, + escrow.refund_deadline, + escrow.escrow_data, + escrow.init_tx_hash, + escrow.init_tx_height, + escrow.claim_tx_hash, + escrow.claim_btc_txid, + escrow.refund_tx_hash, + escrow.status_updated_at, + escrow.created_at, + escrow.updated_at + ) + .execute(self.conn()) + .await?; + Ok(res.rows_affected() > 0) + } + + /// Find a swap escrow by its (normalized) escrow hash. + pub async fn find_swap_escrow( + &mut self, + escrow_hash: &str, + ) -> anyhow::Result> { + let row = + sqlx::query_as::<_, SwapEscrow>("SELECT * FROM swap_escrow WHERE escrow_hash = ?") + .bind(escrow_hash) + .fetch_optional(self.conn()) + .await?; + Ok(row) + } + + /// Find swap escrows with filtering and pagination. + /// + /// Returns the matching page together with the total match count. + pub async fn find_swap_escrows( + &mut self, + params: SwapEscrowQuery, + ) -> anyhow::Result<(Vec, i64)> { + let mut count_params = params.clone(); + (count_params.order, count_params.offset) = (None, None); + let escrows_query_builder = params.get_query_builder("SELECT * FROM swap_escrow"); + let count_query_builder = + count_params.get_query_builder("SELECT count(*) as total_escrows FROM swap_escrow"); + let sql = escrows_query_builder.get_sql(); + let mut data_query = sqlx::query_as::<_, SwapEscrow>(&sql); + data_query = escrows_query_builder.query_as(data_query); + let count_sql = count_query_builder.get_sql(); + let mut count_query = sqlx::query(&count_sql); + count_query = count_query_builder.query(count_query); + Ok(( + data_query.fetch_all(self.conn()).await?, + count_query.fetch_one(self.conn()).await?.get::("total_escrows"), + )) + } + + /// Update swap escrow fields using the builder pattern. + pub async fn update_swap_escrow(&mut self, params: &SwapEscrowUpdate) -> anyhow::Result { + let query_builder = params.get_query_builder("swap_escrow"); + let update_sql = query_builder.get_sql(); + let mut query = sqlx::query(&update_sql); + query = query_builder.query(query); + let result = query.execute(self.conn()).await?; + Ok(result.rows_affected() > 0) + } + + /// Mark still-initializing swap escrows whose refund deadline has passed + /// as timed out. Returns the number of escrows transitioned. + pub async fn timeout_expired_swap_escrows(&mut self, current_time: i64) -> anyhow::Result { + let initialize_status = SwapEscrowStatus::Initialize.to_string(); + let timeout_status = SwapEscrowStatus::Timeout.to_string(); + let row = sqlx::query!( + "UPDATE swap_escrow SET status = ?, status_updated_at = ?, updated_at = ? \ + WHERE status = ? AND refund_deadline > 0 AND refund_deadline < ?", + timeout_status, + current_time, + current_time, + initialize_status, + current_time + ) + .execute(self.conn()) + .await?; + Ok(row.rows_affected()) + } + /// Update expired instances status /// /// Updates the status of instances that have expired based on a time threshold. @@ -1344,9 +1468,6 @@ impl<'a> StorageProcessor<'a> { if !params.has_updates() { return Ok(false); } - if params.instance_id.is_none() && params.escrow_hash.is_none() { - anyhow::bail!("instance update requires instance_id or escrow_hash"); - } let query_builder = params.get_query_builder("instance"); // Get SQL and parameters let update_sql = query_builder.get_sql(); @@ -2168,9 +2289,8 @@ impl<'a> StorageProcessor<'a> { Ok(res) } - pub async fn get_sum_bridge_txn( + pub async fn get_sum_bridge_in_txn( &mut self, - is_bridge_in: bool, statuses: &[String], ) -> anyhow::Result<(i64, i64)> { #[derive(sqlx::FromRow)] @@ -2182,8 +2302,7 @@ impl<'a> StorageProcessor<'a> { let query_str = format!( "SELECT SUM(amount) AS total, COUNT(*) AS tx_count FROM instance - WHERE is_bridge_in = {} AND status IN ({})", - is_bridge_in, + WHERE status IN ({})", create_place_holders(statuses) ); let mut query = sqlx::query_as::<_, BridgeInRow>(&query_str); @@ -2864,6 +2983,40 @@ impl<'a> StorageProcessor<'a> { Ok(result.rows_affected() > 0) } + /// Queue a graph-setup notification for a bounded retransmission window. + /// + /// A non-empty `ack_peer_id` is the only ACK that may stop this retry + /// loop early. Broadcast messages without an authenticated recipient keep + /// publishing until their window expires. + pub async fn enqueue_p2p_outbox_retry_message( + &mut self, + message_id: &str, + msg_type: &str, + content: &[u8], + retry_until: i64, + retry_interval_secs: i64, + ack_peer_id: Option<&str>, + ) -> anyhow::Result { + let now = get_current_timestamp_secs(); + let result = sqlx::query( + "INSERT INTO p2p_outbox \ + (message_id, msg_type, content, state, attempt_count, next_retry_at, lease_until, retry_until, retry_interval_secs, ack_peer_id, created_at, updated_at) \ + VALUES (?, ?, ?, 'Pending', 0, 0, 0, ?, ?, ?, ?, ?) \ + ON CONFLICT(message_id) DO NOTHING", + ) + .bind(message_id) + .bind(msg_type) + .bind(content) + .bind(retry_until) + .bind(retry_interval_secs) + .bind(ack_peer_id.unwrap_or_default()) + .bind(now) + .bind(now) + .execute(self.conn()) + .await?; + Ok(result.rows_affected() > 0) + } + pub async fn claim_p2p_outbox_messages( &mut self, now: i64, @@ -2871,14 +3024,16 @@ impl<'a> StorageProcessor<'a> { limit: i64, ) -> anyhow::Result> { let rows = sqlx::query( - "SELECT message_id, msg_type, content, state, attempt_count, next_retry_at, lease_until, last_error, created_at \ + "SELECT message_id, msg_type, content, state, attempt_count, next_retry_at, lease_until, last_error, retry_until, retry_interval_secs, ack_peer_id, created_at \ FROM p2p_outbox \ - WHERE (state = 'Pending' AND next_retry_at <= ?) \ + WHERE ((state = 'Pending' AND next_retry_at <= ?) \ OR (state = 'Processing' AND lease_until <= ?) \ + ) AND (retry_until = 0 OR retry_until > ?) \ ORDER BY created_at ASC LIMIT ?", ) .bind(now) .bind(now) + .bind(now) .bind(limit) .fetch_all(self.conn()) .await?; @@ -2907,6 +3062,71 @@ impl<'a> StorageProcessor<'a> { Ok(claimed) } + /// End a bounded-retry message once its delivery window is exhausted. + pub async fn expire_p2p_outbox_retry_messages(&mut self, now: i64) -> anyhow::Result { + let result = sqlx::query( + "UPDATE p2p_outbox SET state = 'RetryExhausted', content = X'', lease_until = 0, next_retry_at = 0, \ + retry_until = 0, retry_interval_secs = 0, ack_peer_id = '', \ + last_error = 'retry window expired without expected ACK', updated_at = ? \ + WHERE retry_until > 0 AND retry_until <= ? AND state IN ('Pending', 'Processing')", + ) + .bind(now) + .bind(now) + .execute(self.conn()) + .await?; + Ok(result.rows_affected()) + } + + pub async fn schedule_p2p_outbox_retry( + &mut self, + message_id: &str, + next_retry_at: i64, + ) -> anyhow::Result { + let result = sqlx::query( + "UPDATE p2p_outbox SET state = 'Pending', lease_until = 0, next_retry_at = ?, updated_at = ? \ + WHERE message_id = ? AND state = 'Processing' AND retry_until > 0", + ) + .bind(next_retry_at) + .bind(get_current_timestamp_secs()) + .bind(message_id) + .execute(self.conn()) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn acknowledge_p2p_outbox_message( + &mut self, + message_id: &str, + peer_id: &str, + ) -> anyhow::Result { + let result = sqlx::query( + "UPDATE p2p_outbox SET state = 'Processed', content = X'', lease_until = 0, next_retry_at = 0, updated_at = ? \ + WHERE message_id = ? AND retry_until > 0 AND ack_peer_id = ? \ + AND state IN ('Pending', 'Processing')", + ) + .bind(get_current_timestamp_secs()) + .bind(message_id) + .bind(peer_id) + .execute(self.conn()) + .await?; + Ok(result.rows_affected() > 0) + } + + /// Stop a local setup notification once a later protocol step makes it + /// obsolete and release its potentially large payload immediately. + pub async fn cancel_p2p_outbox_message(&mut self, message_id: &str) -> anyhow::Result { + let result = sqlx::query( + "UPDATE p2p_outbox SET state = 'Cancelled', content = X'', lease_until = 0, next_retry_at = 0, \ + updated_at = ? \ + WHERE message_id = ? AND state IN ('Pending', 'Processing')", + ) + .bind(get_current_timestamp_secs()) + .bind(message_id) + .execute(self.conn()) + .await?; + Ok(result.rows_affected() > 0) + } + pub async fn complete_p2p_outbox_message(&mut self, message_id: &str) -> anyhow::Result { let result = sqlx::query( "UPDATE p2p_outbox SET state = 'Processed', content = X'', lease_until = 0, next_retry_at = 0, updated_at = ? \ @@ -4185,7 +4405,13 @@ mod tests { let mut s = db.acquire().await.unwrap(); sqlx::query( - "INSERT INTO instance (instance_id, is_bridge_in, status, created_at, updated_at) VALUES ('in-1', 1, 'Pending', 20, 20), ('in-2', 1, 'Pending', 10, 10), ('out-1', 0, 'Completed', 30, 30)", + "INSERT INTO instance (instance_id, status, created_at, updated_at) VALUES ('in-1', 'Pending', 20, 20), ('in-2', 'Pending', 10, 10)", + ) + .execute(s.conn()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO swap_escrow (escrow_hash, status, created_at, updated_at) VALUES ('0xaa', 'Initialize', 30, 30)", ) .execute(s.conn()) .await @@ -4205,17 +4431,23 @@ mod tests { let counts = s.node_metrics_state_counts().await.unwrap(); assert_eq!( - counts.iter().find(|count| { - count.category == "instance_bridge_in" && count.state == "Pending" - }), + counts.iter().find(|count| count.category == "instance" && count.state == "Pending"), Some(&MetricsStateCount { - category: "instance_bridge_in".to_string(), + category: "instance".to_string(), state: "Pending".to_string(), count: 2, oldest_created_at: Some(10), last_success_at: None, }) ); + assert_eq!( + counts + .iter() + .find(|count| count.category == "swap_escrow" && count.state == "Initialize") + .unwrap() + .count, + 1 + ); assert_eq!( counts .iter() diff --git a/crates/store/src/schema.rs b/crates/store/src/schema.rs index 73c4d82d6..d46b35697 100644 --- a/crates/store/src/schema.rs +++ b/crates/store/src/schema.rs @@ -219,8 +219,13 @@ pub enum InstanceBridgeInStatus { Canceled, // UserCanceled } +/// Lifecycle of a swap-based bridge-out escrow. +/// +/// `Initialize`, `Claim`, and `Refund` mirror the swap contract events; +/// `Timeout` is derived locally when the refund deadline passes without an +/// observed claim. A later on-chain Claim/Refund event still overrides it. #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Display, EnumString)] -pub enum InstanceBridgeOutStatus { +pub enum SwapEscrowStatus { #[default] Initialize, Claim, @@ -228,23 +233,56 @@ pub enum InstanceBridgeOutStatus { Refund, } +/// Normalize an escrow hash to its canonical storage form: +/// lowercase, 0x-prefixed 32-byte hex. +pub fn normalize_escrow_hash(escrow_hash: &str) -> String { + let stripped = escrow_hash + .strip_prefix("0x") + .or_else(|| escrow_hash.strip_prefix("0X")) + .unwrap_or(escrow_hash); + format!("0x{}", stripped.to_ascii_lowercase()) +} + +/// A swap-based bridge-out escrow, keyed by the on-chain escrow hash. +/// Fully independent of the BitVM `Instance`/`Graph` flow. +#[derive(Clone, FromRow, Debug, Serialize, Deserialize, Default)] +pub struct SwapEscrow { + pub escrow_hash: String, // 0x-prefixed 32-byte hex, lowercase + pub network: String, + pub status: String, // SwapEscrowStatus + pub offerer_addr: String, // goat account funding the escrow + pub claimer_addr: String, // goat account entitled to claim + pub btc_addr: String, // btc address receiving the payout + pub token: String, // escrow token contract address + pub amount: String, // escrow amount, U256 decimal string + pub refund_deadline: i64, // unix secs after which refund is possible + pub escrow_data: Option, // hex abi-encoded EscrowData from the Initialize tx + pub init_tx_hash: String, // swap Initialize goat tx + pub init_tx_height: i64, + pub claim_tx_hash: String, // swap Claim goat tx + pub claim_btc_txid: Option, // btc payout tx committed by the claim + pub refund_tx_hash: String, // swap Refund goat tx + pub status_updated_at: i64, + pub created_at: i64, + pub updated_at: i64, +} + #[derive(Clone, FromRow, Debug, Serialize, Deserialize, Default)] pub struct Instance { pub instance_id: Uuid, - pub is_bridge_in: bool, pub network: String, pub from_addr: String, pub to_addr: String, // goat deposit addr pub amount: i64, pub fees: UInt64Array3, pub input_utxos: String, // init should been [] - pub status: String, // InstanceBridgeInStatus | InstanceBridgeOutStatus - pub goat_tx_hash: String, // bridgeIn:pegin Request tx || bridgeOut goat tx + pub status: String, // InstanceBridgeInStatus + pub goat_tx_hash: String, // pegin Request tx pub goat_tx_height: i64, pub user_xonly_pubkey: ByteArray32, pub user_change_addr: String, pub user_refund_addr: String, - pub btc_txid: Option, // bridgeIn: Pegin Prepare Request tx || bridgeOut Btc tx + pub btc_txid: Option, // Pegin Prepare Request tx pub btc_height: i64, pub pegin_confirm_txid: Option, // btc txid pub pegin_cancel_txid: Option, // btc txid @@ -252,10 +290,7 @@ pub struct Instance { pub committees_answers: IndexMap>, pub pegin_data_tx_hash: String, pub parameters: Option, - pub escrow_hash: Option, - pub bridge_out_lock_time: i64, pub post_pegin_txhash: Option, - pub bridge_out_amount: String, pub status_updated_at: i64, pub created_at: i64, pub updated_at: i64, @@ -565,6 +600,9 @@ pub struct P2pOutboxMessage { pub next_retry_at: i64, pub lease_until: i64, pub last_error: Option, + pub retry_until: i64, + pub retry_interval_secs: i64, + pub ack_peer_id: String, pub created_at: i64, } @@ -728,10 +766,6 @@ pub enum GoatTxType { WithdrawHappyPath, WithdrawUnhappyPath, WithdrawDisproved, - // swap - SwapInitialize, - SwapClaim, - SwapRefund, } #[derive(Clone, Debug, Serialize, Deserialize, Default, Display, EnumString)] diff --git a/node/Cargo.toml b/node/Cargo.toml index e34be8c1b..69732ee83 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -31,10 +31,6 @@ path = "src/bin/send_rbf.rs" name = "pegout" path = "src/bin/send_pegout.rs" -[[bin]] -name = "bridge-out" -path = "src/bin/send_bridge_out.rs" - [[bin]] name = "update-db" path = "src/bin/db_inject.rs" diff --git a/node/README.md b/node/README.md index c5dbef5f5..e1c744a1c 100644 --- a/node/README.md +++ b/node/README.md @@ -707,7 +707,7 @@ flowchart TB IM1["instance_answers_monitor"] IM2["instance_window_expiration_monitor"] IM3["instance_btc_tx_monitor"] - IM4["instance_bridge_out_monitor"] + IM4["swap_escrow_timeout_monitor"] IM5["instance_committee_key_cleanup_monitor"] end @@ -734,7 +734,7 @@ flowchart TB | `instance_answers_monitor` | 20s | Track committee responses | | `instance_window_expiration_monitor` | 20s | Handle response window timeouts | | `instance_btc_tx_monitor` | 20s | Track pegin/confirm/cancel BTC transaction confirmations | -| `instance_bridge_out_monitor` | 20s | Track bridge-out deadlines and timeout transitions | +| `swap_escrow_timeout_monitor` | 20s | Mark swap escrows past their refund deadline as timed out | | `instance_committee_key_cleanup_monitor` | 20s | Scan `cache/committee-instance-keys/` and delete expired key envelopes after configurable pegin-confirm timelock | | `spv_header_hash_update` | Periodic | Update SPV header hashes | @@ -833,7 +833,8 @@ Useful test calls: ```bash curl http://127.0.0.1:18080/v1/nodes/overview -curl 'http://127.0.0.1:18080/v1/instances?is_bridge_in=true' +curl http://127.0.0.1:18080/v1/instances +curl http://127.0.0.1:18080/v1/swaps curl http://127.0.0.1:18080/v1/graphs ``` @@ -914,7 +915,8 @@ Relayer nodes should: | `/v1/graphs/:id/tx?tx_name=cur-pre-kickoff.hex` | GET | Get specific transaction hex | | `/v1/graphs/ready-to-kickoff` | GET | Get graphs ready for kickoff | | `/bridge_in_request` | POST | Initiate Bridge-In request | -| `/bridge_out_init` | POST | Initiate Bridge-Out request | +| `/v1/swaps` | GET | List swap bridge-out escrows | +| `/v1/swaps/:escrow_hash` | GET | Get swap escrow details | | `/challenge` | POST | Submit challenge | | `/metrics` | GET | Prometheus metrics | diff --git a/node/src/action.rs b/node/src/action.rs index eee7d4322..ab68d69b9 100644 --- a/node/src/action.rs +++ b/node/src/action.rs @@ -2,7 +2,10 @@ #![allow(clippy::single_match)] #![allow(clippy::collapsible_else_if)] -use crate::env::{get_local_node_info, get_p2p_inbox_batch_size, get_p2p_outbox_batch_size}; +use crate::env::{ + get_local_node_info, get_p2p_graph_setup_retry_interval_secs, + get_p2p_graph_setup_retry_window_secs, get_p2p_inbox_batch_size, get_p2p_outbox_batch_size, +}; use crate::handle::{ HandlerContext, HeavyTaskContext, dispatch as handle_dispatch, heavy_task_from_content, is_heavy_task_message_type, run_heavy_task, @@ -13,7 +16,7 @@ use crate::rpc_service::current_time_secs; use crate::utils::*; use alloy::primitives::Address as EvmAddress; use anyhow::{Context, Result, anyhow, bail}; -use bitcoin::{PublicKey, Txid}; +use bitcoin::{PublicKey, Txid, XOnlyPublicKey}; use bitvm_lib::actors::Actor; use bitvm_lib::babe_adapter::{BabeBundleBuilder, CACSetupPackage}; use bitvm_lib::committee::*; @@ -27,8 +30,11 @@ use client::{ use libp2p::gossipsub::MessageId; use libp2p::{PeerId, Swarm, gossipsub}; use musig2::{PartialSignature, PubNonce}; -use secp256k1::schnorr::Signature as SchnorrSignature; +use secp256k1::{ + Keypair, Message as SecpMessage, SECP256K1, schnorr::Signature as SchnorrSignature, +}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::fmt; use std::str::FromStr; use std::sync::{Arc, LazyLock, Mutex}; @@ -267,6 +273,7 @@ pub enum GOATMessageContent { GenCircuits(GenCircuits), CutCircuits(CutCircuits), SolderingProofReady(SolderingProofReady), + GraphSetupAck(GraphSetupAck), VerifierGraphParamsEndorsement(VerifierGraphParamsEndorsement), NonceGeneration(NonceGeneration), CommitteePresign(CommitteePresign), @@ -310,7 +317,8 @@ impl GOATMessageContent { Self::RequestNodeInfo(_) | Self::ResponseNodeInfo(_) | Self::SyncGraphRequest(_) - | Self::SyncGraph(_) => P2PMessageDelivery::Immediate, + | Self::SyncGraph(_) + | Self::GraphSetupAck(_) => P2PMessageDelivery::Immediate, _ => P2PMessageDelivery::Inbox, } } @@ -326,6 +334,7 @@ impl GOATMessageContent { Self::GenCircuits(_) => "GenCircuits", Self::CutCircuits(_) => "CutCircuits", Self::SolderingProofReady(_) => "SolderingProofReady", + Self::GraphSetupAck(_) => "GraphSetupAck", Self::VerifierGraphParamsEndorsement(_) => "VerifierGraphParamsEndorsement", Self::NonceGeneration(_) => "NonceGeneration", Self::CommitteePresign(_) => "CommitteePresign", @@ -432,6 +441,56 @@ pub struct ConfirmInstance { pub struct InitGraph { pub instance_id: Uuid, pub graph_id: Uuid, + pub operator_pubkey: PublicKey, + pub operator_peer_id: Vec, + pub signature: SchnorrSignature, +} + +const INIT_GRAPH_SIGNATURE_DOMAIN: &[u8] = b"bitvm2-node/init-graph/v1"; + +fn init_graph_signature_message( + instance_id: Uuid, + graph_id: Uuid, + operator_pubkey: &PublicKey, + operator_peer_id: &[u8], +) -> SecpMessage { + let mut hasher = Sha256::new(); + hasher.update(INIT_GRAPH_SIGNATURE_DOMAIN); + hasher.update(instance_id.as_bytes()); + hasher.update(graph_id.as_bytes()); + hasher.update(operator_pubkey.to_bytes()); + hasher.update((operator_peer_id.len() as u32).to_be_bytes()); + hasher.update(operator_peer_id); + SecpMessage::from_digest(hasher.finalize().into()) +} + +pub fn sign_init_graph( + instance_id: Uuid, + graph_id: Uuid, + operator_keypair: &Keypair, + operator_peer_id: Vec, +) -> InitGraph { + let operator_pubkey = operator_keypair.public_key().into(); + let signature = SECP256K1.sign_schnorr( + &init_graph_signature_message(instance_id, graph_id, &operator_pubkey, &operator_peer_id), + operator_keypair, + ); + InitGraph { instance_id, graph_id, operator_pubkey, operator_peer_id, signature } +} + +pub fn verify_init_graph_signature(message: &InitGraph) -> bool { + SECP256K1 + .verify_schnorr( + &message.signature, + &init_graph_signature_message( + message.instance_id, + message.graph_id, + &message.operator_pubkey, + &message.operator_peer_id, + ), + &XOnlyPublicKey::from(message.operator_pubkey), + ) + .is_ok() } #[derive(Serialize, Deserialize, Clone)] pub struct GenCircuits { @@ -445,17 +504,100 @@ pub struct CutCircuits { pub instance_id: Uuid, pub graph_id: Uuid, pub verifier_pubkey: PublicKey, - pub verifier_index: usize, + pub candidate_index: usize, pub selected_circuit_indexes: Vec, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct SolderingProofReady { pub instance_id: Uuid, pub graph_id: Uuid, - pub verifier_index: usize, + pub candidate_index: usize, pub payload_hash: [u8; 32], pub total_len: usize, } + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +pub enum GraphSetupStage { + GenCircuits, + CutCircuits, + SolderingProofReady, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GraphSetupAck { + pub outbox_id: String, + pub instance_id: Uuid, + pub graph_id: Uuid, + pub stage: GraphSetupStage, + pub acknowledger_peer_id: String, +} + +pub fn graph_setup_outbox_id(content: &GOATMessageContent) -> Option { + match content { + GOATMessageContent::InitGraph(message) => Some(format!("init-graph:{}", message.graph_id)), + GOATMessageContent::GenCircuits(message) => Some(format!( + "gen-circuits:{}:{}:{}", + message.instance_id, message.graph_id, message.verifier_pubkey + )), + GOATMessageContent::CutCircuits(message) => Some(format!( + "cut-circuits:{}:{}:{}", + message.instance_id, message.graph_id, message.verifier_pubkey + )), + GOATMessageContent::SolderingProofReady(message) => Some(format!( + "soldering-proof-ready:{}:{}:{}", + message.graph_id, + message.candidate_index, + hex::encode(message.payload_hash), + )), + _ => None, + } +} + +fn graph_setup_ack(content: &GOATMessageContent) -> Option { + let (instance_id, graph_id, stage) = match content { + GOATMessageContent::GenCircuits(message) => { + (message.instance_id, message.graph_id, GraphSetupStage::GenCircuits) + } + GOATMessageContent::CutCircuits(message) => { + (message.instance_id, message.graph_id, GraphSetupStage::CutCircuits) + } + GOATMessageContent::SolderingProofReady(message) => { + (message.instance_id, message.graph_id, GraphSetupStage::SolderingProofReady) + } + _ => return None, + }; + Some(GraphSetupAck { + outbox_id: graph_setup_outbox_id(content)?, + instance_id, + graph_id, + stage, + acknowledger_peer_id: get_local_node_info().peer_id, + }) +} + +pub async fn enqueue_graph_setup_outbox_message( + local_db: &LocalDB, + message: GOATMessage, + ack_peer_id: Option<&str>, +) -> Result { + let outbox_id = graph_setup_outbox_id(&message.content) + .ok_or_else(|| anyhow!("not a graph setup message"))?; + let serialized = message.serialize_message().await?; + let now = current_time_secs(); + local_db + .acquire() + .await? + .enqueue_p2p_outbox_retry_message( + &outbox_id, + message.content.event_type(), + &serialized, + now + get_p2p_graph_setup_retry_window_secs(), + get_p2p_graph_setup_retry_interval_secs(), + ack_peer_id, + ) + .await?; + Ok(outbox_id) +} #[derive(Serialize, Deserialize, Clone)] pub struct VerifierGraphParamsEndorsement { pub instance_id: Uuid, @@ -750,7 +892,26 @@ pub async fn handle_inbound_p2p_message( match decoded.content.p2p_delivery() { P2PMessageDelivery::Inbox => { - enqueue_p2p_message(local_db, actor, from_peer_id, id, message, &decoded).await + enqueue_p2p_message(local_db, actor, from_peer_id, id, message, &decoded).await?; + if let Some(ack) = graph_setup_ack(&decoded.content) { + // ACKs are deliberately ephemeral. A duplicate delivery is + // acknowledged again, which lets the sender recover when its + // previous ACK was dropped. + if let Err(error) = send_to_peer( + swarm, + GOATMessage::new(Actor::All, GOATMessageContent::GraphSetupAck(ack)), + ) + .await + { + tracing::debug!( + event = "p2p_graph_setup_ack", + outcome = "publish_failed", + error = %error, + "inbound graph-setup message remains durable; sender will retry" + ); + } + } + Ok(()) } P2PMessageDelivery::Immediate => { tracing::debug!( @@ -1275,11 +1436,21 @@ async fn handle_p2p_outbox_messages( ) -> Result<()> { let now = current_time_secs(); let mut storage = local_db.start_immediate_transaction().await?; + let expired = storage.expire_p2p_outbox_retry_messages(now).await?; let messages = storage .claim_p2p_outbox_messages(now, now + P2P_INBOX_LEASE_SECS, get_p2p_outbox_batch_size()) .await?; storage.commit().await?; + if expired > 0 { + tracing::warn!( + event = "p2p_outbox", + outcome = "retry_window_expired", + expired, + "graph-setup outbound messages reached their retry window without the expected ACK" + ); + } + for message in messages { let outbound = match GOATMessage::deserialize_message(&message.content).await { Ok(message) => message, @@ -1304,14 +1475,21 @@ async fn handle_p2p_outbox_messages( let mut storage = local_db.acquire().await?; match result { Ok(_) => { - storage.complete_p2p_outbox_message(&message.message_id).await?; - tracing::info!( - event = "p2p_outbox", - outcome = "published", - message_id = %message.message_id, - message_type = %message.msg_type, - "published durable outbound P2P message" - ); + if message.retry_until > 0 { + let next_retry_at = current_time_secs() + message.retry_interval_secs; + storage.schedule_p2p_outbox_retry(&message.message_id, next_retry_at).await?; + tracing::info!( + event = "p2p_outbox", + outcome = "published_retry_window", + message_id = %message.message_id, + message_type = %message.msg_type, + next_retry_at, + retry_until = message.retry_until, + "published graph-setup P2P message; awaiting ACK or retry window expiry" + ); + } else { + storage.complete_p2p_outbox_message(&message.message_id).await?; + } } Err(error) => { let retry_after_secs = p2p_retry_delay_secs(message.attempt_count); @@ -1872,7 +2050,7 @@ mod tests { let ready = SolderingProofReady { instance_id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(), graph_id: Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap(), - verifier_index: 3, + candidate_index: 3, payload_hash: [0xabu8; 32], total_len: 1024, }; @@ -1882,7 +2060,7 @@ mod tests { assert!(object.contains_key("instance_id")); assert!(object.contains_key("graph_id")); - assert!(object.contains_key("verifier_index")); + assert!(object.contains_key("candidate_index")); assert!(object.contains_key("payload_hash")); assert!(object.contains_key("total_len")); assert!(!object.contains_key("payload_path")); diff --git a/node/src/bin/db_inject.rs b/node/src/bin/db_inject.rs index 442fdca3b..e42395319 100644 --- a/node/src/bin/db_inject.rs +++ b/node/src/bin/db_inject.rs @@ -84,6 +84,7 @@ fn infer_business_id(content: &GOATMessageContent) -> Option { GOATMessageContent::GenCircuits(v) => Some(v.graph_id), GOATMessageContent::CutCircuits(v) => Some(v.graph_id), GOATMessageContent::SolderingProofReady(v) => Some(v.graph_id), + GOATMessageContent::GraphSetupAck(_) => None, GOATMessageContent::VerifierGraphParamsEndorsement(v) => Some(v.graph_id), GOATMessageContent::CreateGraph(v) => Some(v.graph_id), GOATMessageContent::NonceGeneration(v) => Some(v.graph_id), diff --git a/node/src/bin/mock_rpc.rs b/node/src/bin/mock_rpc.rs index 105066264..6f011f30d 100644 --- a/node/src/bin/mock_rpc.rs +++ b/node/src/bin/mock_rpc.rs @@ -24,9 +24,8 @@ use prometheus_client::registry::Registry; use secp256k1::Secp256k1; use store::localdb::{GraphRuntimeUpdate, StorageProcessor}; use store::{ - BridgeOutGlobalStats, GoatTxProcessingStatus, GoatTxRecord, GoatTxType, Graph, GraphStatus, - GraphStatusSource, Instance, InstanceBridgeInStatus, InstanceBridgeOutStatus, Node, - UInt64Array3, create_local_db, + BridgeOutGlobalStats, Graph, GraphStatus, GraphStatusSource, Instance, InstanceBridgeInStatus, + Node, SwapEscrow, SwapEscrowStatus, UInt64Array3, create_local_db, }; use tokio::signal; use tokio_util::sync::CancellationToken; @@ -55,7 +54,7 @@ struct Opts { #[derive(Debug)] struct MockSeedSummary { bridge_in_instance_id: Uuid, - bridge_out_instance_id: Uuid, + swap_escrow_hash: String, ready_graph_id: Uuid, challenge_graph_id: Uuid, operator_pubkey: String, @@ -147,7 +146,6 @@ fn seeded_bridge_in_instance( let utxo = vec![Utxo { txid: [utxo_byte; 32], vout: 0, amount_sats: amount as u64 }]; Ok(Instance { instance_id, - is_bridge_in: true, network: Network::Testnet4.to_string(), from_addr: get_rand_btc_address_p2wpkh(Network::Testnet4), to_addr: mock_goat_addr(0x31), @@ -161,7 +159,6 @@ fn seeded_bridge_in_instance( user_refund_addr: get_rand_btc_address_p2wpkh(Network::Testnet4), pegin_data_tx_hash: mock_tx_hash(0xa2), post_pegin_txhash: Some(mock_tx_hash(0xa3)), - bridge_out_amount: "0".to_string(), status_updated_at: now - 120, created_at: now - 3_600, updated_at: now, @@ -169,21 +166,20 @@ fn seeded_bridge_in_instance( }) } -fn seeded_bridge_out_instance(instance_id: Uuid, now: i64, escrow_hash: String) -> Instance { - Instance { - instance_id, - is_bridge_in: false, +fn seeded_swap_escrow(escrow_hash: String, now: i64) -> SwapEscrow { + SwapEscrow { + escrow_hash, network: Network::Testnet4.to_string(), - from_addr: mock_goat_addr(0x41), - to_addr: get_rand_btc_address_p2wpkh(Network::Testnet4), - amount: 0, - input_utxos: "[]".to_string(), - status: InstanceBridgeOutStatus::Initialize.to_string(), - goat_tx_hash: mock_tx_hash(0xb1), - goat_tx_height: 123_500, - escrow_hash: Some(escrow_hash), - bridge_out_amount: "25000000".to_string(), - bridge_out_lock_time: now + 3_600, + status: SwapEscrowStatus::Initialize.to_string(), + offerer_addr: mock_goat_addr(0x41), + claimer_addr: mock_goat_addr(0x42), + btc_addr: get_rand_btc_address_p2wpkh(Network::Testnet4), + token: mock_goat_addr(0x43), + amount: "25000000".to_string(), + refund_deadline: now + 3_600, + escrow_data: Some(hex::encode([0xe1; 64])), + init_tx_hash: mock_tx_hash(0xb1), + init_tx_height: 123_500, status_updated_at: now - 60, created_at: now - 900, updated_at: now, @@ -281,7 +277,6 @@ async fn seed_mock_data( let graph_from_addr = mock_goat_addr(0x51); let bridge_in_instance_id = mock_uuid("11111111-1111-1111-1111-111111111111"); let bridge_in_pending_id = mock_uuid("11111111-1111-1111-1111-111111111112"); - let bridge_out_instance_id = mock_uuid("22222222-2222-2222-2222-222222222222"); let ready_graph_id = mock_uuid("33333333-3333-3333-3333-333333333333"); let challenge_graph_id = mock_uuid("33333333-3333-3333-3333-333333333334"); let escrow_hash = mock_tx_hash(0xc1); @@ -309,7 +304,7 @@ async fn seed_mock_data( now, 0x12, )?; - let bridge_out = seeded_bridge_out_instance(bridge_out_instance_id, now, escrow_hash.clone()); + let swap_escrow = seeded_swap_escrow(escrow_hash.clone(), now); let none_sub_status = r#"{"watchtower_challenge_status":[],"verifier_challenge_status":[],"disprove_type":null,"disprove_index":-1}"#.to_string(); let challenge_sub_status = r#"{"watchtower_challenge_status":[true,false],"verifier_challenge_status":["None"],"disprove_type":null,"disprove_index":-1}"#.to_string(); @@ -340,24 +335,13 @@ async fn seed_mock_data( for node in [current_node, committee_node, operator_node, watchtower_node] { tx.upsert_node(&node).await?; } - for instance in [bridge_in_success, bridge_in_pending, bridge_out] { + for instance in [bridge_in_success, bridge_in_pending] { tx.upsert_instance(&instance).await?; } + tx.insert_swap_escrow_if_absent(&swap_escrow).await?; for graph in [ready_graph, challenge_graph] { seed_graph_runtime(&mut tx, &graph).await?; } - tx.upsert_goat_tx_record(&GoatTxRecord { - instance_id: bridge_out_instance_id, - graph_id: Uuid::nil(), - tx_type: GoatTxType::SwapInitialize.to_string(), - tx_hash: mock_tx_hash(0xe1), - height: 123_501, - is_local: true, - processing_status: GoatTxProcessingStatus::Processed.to_string(), - extra: Some(escrow_hash), - created_at: now, - }) - .await?; tx.upsert_bridge_out_global_stats(&BridgeOutGlobalStats { id: 1, initial_txn: 1, @@ -374,7 +358,7 @@ async fn seed_mock_data( Ok(MockSeedSummary { bridge_in_instance_id, - bridge_out_instance_id, + swap_escrow_hash: escrow_hash, ready_graph_id, challenge_graph_id, operator_pubkey, @@ -417,7 +401,7 @@ async fn main() -> Result<()> { println!("Gateway contract: {MOCK_GATEWAY_CONTRACT}"); println!("Swap contract: {MOCK_SWAP_CONTRACT}"); println!("Bridge-in instance: {}", seed_summary.bridge_in_instance_id); - println!("Bridge-out instance: {}", seed_summary.bridge_out_instance_id); + println!("Swap escrow: {}", seed_summary.swap_escrow_hash); println!("Ready graph: {}", seed_summary.ready_graph_id); println!("Challenge graph: {}", seed_summary.challenge_graph_id); println!("Operator pubkey: {}", seed_summary.operator_pubkey); diff --git a/node/src/bin/send_bridge_out.rs b/node/src/bin/send_bridge_out.rs deleted file mode 100644 index 389301500..000000000 --- a/node/src/bin/send_bridge_out.rs +++ /dev/null @@ -1,1050 +0,0 @@ -//! bridge-out: user bridge-out helper (init-tag + escrow-data + swap-initialize) via the node API. -//! -//! Modes: -//! - init-tag: initialize a bridge-out instance record -//! - escrow-data: query escrow data by instance id -//! - swap-initialize: call swap contract initialize and derive escrow hash from tx logs -//! -//! The node API must be reachable at --rpc-url for init-tag/escrow-data. -//! The GOAT RPC/private key environment must be configured for swap-initialize. - -use alloy::eips::BlockNumberOrTag; -use alloy::primitives::{Address as EvmAddress, Bytes, U256}; -use alloy::providers::{Provider, ProviderBuilder}; -use anyhow::{Context, Result, anyhow, bail}; -use bitvm_noded::env::{ - ENV_GOAT_PRIVATE_KEY, ENV_GOAT_SWAP_CONTRACT_ADDRESS, get_goat_network, goat_config_from_env, -}; -use clap::{Parser, Subcommand}; -use client::goat_chain::{GOATClient, SwapEscrowData}; -use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; -use std::str::FromStr; -use std::time::{SystemTime, UNIX_EPOCH}; -use uuid::Uuid; -const PAY_IN_FLAG: u64 = 0x02; -const ZERO_B256_HEX: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; -const DEFAULT_NONCE_TIME_OFFSET_SECS: u64 = 700_000_000; -const DEFAULT_NONCE_RANDOM_BITS: u32 = 24; -const DEFAULT_PRIORITY_FEE_WEI: u64 = 5_000_000; -const DEFAULT_MAX_BASE_FEE_WEI: u64 = 2_000_000_000; -const DEFAULT_BASE_FEE_MULTIPLIER_NUM: u64 = 125; -const DEFAULT_BASE_FEE_MULTIPLIER_DEN: u64 = 100; -const PEG_BTC_DECIMALS: usize = 18; - -#[derive(Parser, Debug)] -#[command( - name = "bridge-out", - version, - about = "Bridge-out helper (via node API and GOAT RPC)", - long_about = "Initialize bridge-out tag, query escrow data, and call swap initialize with escrow hash extraction from tx logs." -)] -struct Args { - /// Node API base URL - #[arg(long, default_value = "http://localhost:8080")] - rpc_url: String, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand, Debug)] -enum Commands { - /// Initialize bridge-out tag - InitTag { - /// Source GOAT address - #[arg(long)] - from_addr: String, - - /// Destination BTC address - #[arg(long)] - to_addr: String, - - /// Escrow hash (0x-prefixed 32-byte hex) - #[arg(long)] - escrow_hash: Option, - - /// Swap initialize tx hash (0x-prefixed 32-byte hex), used to auto-derive escrow hash from logs - #[arg(long)] - swap_init_tx_hash: Option, - - /// GOAT swap contract address; fallback to GOAT_SWAP_CONTRACT_ADDRESS - #[arg(long)] - contract_address: Option, - }, - - /// Query escrow data by instance id - EscrowData { - /// Instance id - #[arg(long)] - instance_id: Uuid, - }, - - /// Fetch escrow params from payInvoice, call swap initialize, and print escrow hash from tx logs - SwapInitialize { - /// payInvoice endpoint URL (chain query included) - #[arg( - long, - default_value = "https://152-32-185-32.nodes.atomiq.exchange:8443/tobtc/payInvoice?chain=GOAT" - )] - pay_invoice_url: String, - - /// BTC receive address, sent to payInvoice as `address` - #[arg(long)] - btc_address: String, - - /// Requested amount in pegBTC (human-readable, e.g. 0.01). Converted to base units automatically. - #[arg(long)] - amount: String, - - /// Whether this quote is exact-in - #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] - exact_in: bool, - - /// Bitcoin confirmation target - #[arg(long, default_value_t = 3)] - confirmation_target: u32, - - /// Required confirmations - #[arg(long, default_value_t = 2)] - confirmations: u32, - - /// Token address - #[arg(long)] - token: String, - - /// Offerer GOAT address - #[arg(long)] - offerer: String, - - /// Additional params JSON object merged into payInvoice body - #[arg(long)] - additional_params_json: Option, - - /// GOAT private key; fallback to GOAT_PRIVATE_KEY - #[arg(long, env = ENV_GOAT_PRIVATE_KEY)] - goat_private_key: Option, - - /// GOAT swap contract address; fallback to GOAT_SWAP_CONTRACT_ADDRESS - #[arg(long)] - contract_address: Option, - - /// Max seconds to wait for tx receipt - #[arg(long, default_value_t = 60)] - max_wait_secs: u64, - }, -} - -#[derive(Debug, Serialize)] -struct BridgeOutInitTagApiRequest { - contract_address: String, - from_addr: String, - to_addr: String, - escrow_hash: String, -} - -#[derive(Debug, Deserialize)] -struct EscrowDataApiResponse { - instance_id: String, - escrow: Option, - error: Option, -} - -#[derive(Debug, Deserialize)] -struct ApiError { - error: String, - message: String, -} - -#[derive(Debug, Deserialize)] -struct PayInvoiceEnvelope { - code: i64, - msg: String, - data: Option, -} - -#[derive(Debug, Deserialize)] -struct PayInvoiceResponseData { - data: PayInvoiceEscrowRaw, - prefix: String, - timeout: String, - signature: String, -} - -#[derive(Debug, Deserialize)] -struct PayInvoiceEscrowRaw { - offerer: String, - claimer: String, - token: String, - #[serde(rename = "refundHandler")] - refund_handler: String, - #[serde(rename = "claimHandler")] - claim_handler: String, - #[serde(rename = "payOut")] - pay_out: bool, - #[serde(rename = "payIn")] - pay_in: bool, - reputation: bool, - sequence: JsonValue, - #[serde(rename = "claimData")] - claim_data: String, - #[serde(rename = "refundData")] - refund_data: String, - amount: JsonValue, - #[serde(rename = "depositToken")] - deposit_token: String, - #[serde(rename = "securityDeposit")] - security_deposit: JsonValue, - #[serde(rename = "claimerBounty")] - claimer_bounty: JsonValue, - #[allow(dead_code)] - kind: Option, - #[serde(rename = "extraData")] - extra_data: Option, - #[serde(rename = "successActionCommitment")] - success_action_commitment: Option, -} - -#[derive(Debug)] -enum EscrowHashInput { - Provided(String), - FromSwapInitTx(String), -} - -fn join_base_path(base_url: &str, path: &str) -> String { - format!("{}{}", base_url.trim_end_matches('/'), path) -} - -fn bridge_out_init_tag_url(base_url: &str) -> String { - join_base_path(base_url, "/v1/instances/bridge-out-init-tag") -} - -fn escrow_data_url(base_url: &str, instance_id: &Uuid) -> String { - join_base_path(base_url, &format!("/v1/instances/{instance_id}/escrow-data")) -} - -fn parse_api_error(body: &str) -> Option { - serde_json::from_str::(body).ok() -} - -fn resolve_contract_address(contract_address: Option) -> Result { - if let Some(addr) = contract_address { - return Ok(addr); - } - - std::env::var(ENV_GOAT_SWAP_CONTRACT_ADDRESS).map_err(|_| { - anyhow!( - "missing --contract-address and env {ENV_GOAT_SWAP_CONTRACT_ADDRESS}, one of them is required" - ) - }) -} - -fn normalize_32byte_hex(value: &str, field_name: &str) -> Result { - let hex = value.strip_prefix("0x").unwrap_or(value); - let bytes = - hex::decode(hex).map_err(|e| anyhow!("{field_name} is not a valid hex string: {e}"))?; - if bytes.len() != 32 { - bail!("{field_name} must be exactly 32 bytes, got {}", bytes.len()); - } - Ok(format!("0x{}", hex::encode(bytes))) -} - -fn parse_hex_bytes(value: &str, field_name: &str) -> Result> { - let hex = value.strip_prefix("0x").unwrap_or(value); - if hex.is_empty() { - return Ok(vec![]); - } - let bytes = - hex::decode(hex).map_err(|e| anyhow!("{field_name} is not a valid hex string: {e}"))?; - Ok(bytes) -} - -fn parse_bytes32(value: &str, field_name: &str) -> Result<[u8; 32]> { - let normalized = normalize_32byte_hex(value, field_name)?; - let hex = normalized.trim_start_matches("0x"); - let bytes = hex::decode(hex).map_err(|e| anyhow!("{field_name} decode failed: {e}"))?; - let mut out = [0u8; 32]; - out.copy_from_slice(&bytes); - Ok(out) -} - -fn parse_u256(value: &str, field_name: &str) -> Result { - if let Some(hex) = value.strip_prefix("0x") { - let bytes = - hex::decode(hex).map_err(|e| anyhow!("{field_name} is not valid hex u256: {e}"))?; - if bytes.len() > 32 { - bail!("{field_name} exceeds 32 bytes for u256"); - } - let mut padded = [0u8; 32]; - padded[32 - bytes.len()..].copy_from_slice(&bytes); - return Ok(U256::from_be_bytes(padded)); - } - - U256::from_str(value).map_err(|e| anyhow!("{field_name} is not valid decimal u256: {e}")) -} - -fn parse_u256_from_json_value(value: &JsonValue, field_name: &str) -> Result { - match value { - JsonValue::String(v) => parse_u256(v, field_name), - JsonValue::Number(v) => parse_u256(&v.to_string(), field_name), - _ => bail!("{field_name} must be a string or number"), - } -} - -fn convert_pegbtc_amount_to_base_units(amount: &str) -> Result { - let amount = amount.trim(); - if amount.is_empty() { - bail!("amount must not be empty"); - } - if amount.starts_with('-') { - bail!("amount must be non-negative"); - } - - let mut parts = amount.split('.'); - let int_part = parts.next().unwrap_or_default(); - let frac_part = parts.next(); - if parts.next().is_some() { - bail!("amount must be a valid decimal number"); - } - - let int_part = if int_part.is_empty() { "0" } else { int_part }; - if !int_part.chars().all(|c| c.is_ascii_digit()) { - bail!("amount integer part must contain digits only"); - } - - let frac_part = frac_part.unwrap_or(""); - if !frac_part.chars().all(|c| c.is_ascii_digit()) { - bail!("amount fractional part must contain digits only"); - } - if frac_part.len() > PEG_BTC_DECIMALS { - bail!("amount has too many decimal places, max {PEG_BTC_DECIMALS}"); - } - - let mut base_units = String::with_capacity(int_part.len() + PEG_BTC_DECIMALS); - base_units.push_str(int_part); - base_units.push_str(frac_part); - base_units.push_str(&"0".repeat(PEG_BTC_DECIMALS - frac_part.len())); - let normalized = base_units.trim_start_matches('0'); - let normalized = if normalized.is_empty() { "0" } else { normalized }; - - U256::from_str(normalized) - .map_err(|e| anyhow!("amount out of range for u256 after conversion: {e}"))?; - Ok(normalized.to_string()) -} - -fn generate_default_nonce(unix_seconds: u64, random_24_bits: u32) -> String { - let timestamp_part = unix_seconds.saturating_sub(DEFAULT_NONCE_TIME_OFFSET_SECS); - let random_part = u64::from(random_24_bits & 0x00ff_ffff); - ((timestamp_part << DEFAULT_NONCE_RANDOM_BITS) | random_part).to_string() -} - -fn generate_default_fee_rate(base_fee_per_gas: U256) -> String { - let mut adjusted_base_fee = (base_fee_per_gas * U256::from(DEFAULT_BASE_FEE_MULTIPLIER_NUM)) - / U256::from(DEFAULT_BASE_FEE_MULTIPLIER_DEN); - let max_base_fee = U256::from(DEFAULT_MAX_BASE_FEE_WEI); - if adjusted_base_fee > max_base_fee { - adjusted_base_fee = max_base_fee; - } - format!("{},{}", adjusted_base_fee, U256::from(DEFAULT_PRIORITY_FEE_WEI)) -} - -async fn generate_default_nonce_and_fee_rate() -> Result<(String, String)> { - let unix_seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is before UNIX_EPOCH")? - .as_secs(); - let nonce = generate_default_nonce(unix_seconds, rand::random::()); - - let cfg = goat_config_from_env().await; - let provider = ProviderBuilder::new().connect_http(cfg.rpc_url.clone()); - let block = provider - .get_block_by_number(BlockNumberOrTag::Latest) - .await - .with_context(|| format!("failed to get latest block from GOAT RPC {}", cfg.rpc_url))? - .ok_or_else(|| anyhow!("latest block not found on GOAT RPC {}", cfg.rpc_url))?; - let base_fee_per_gas = block.header.base_fee_per_gas.ok_or_else(|| { - anyhow!("latest block missing base_fee_per_gas on GOAT RPC {}", cfg.rpc_url) - })?; - let fee_rate = generate_default_fee_rate(U256::from(base_fee_per_gas)); - Ok((nonce, fee_rate)) -} - -fn parse_json_object(value: &str, field_name: &str) -> Result> { - let parsed: JsonValue = - serde_json::from_str(value).map_err(|e| anyhow!("{field_name} is not valid JSON: {e}"))?; - match parsed { - JsonValue::Object(map) => Ok(map), - _ => bail!("{field_name} must be a JSON object"), - } -} - -fn parse_extra_data_from_json(value: Option<&JsonValue>) -> Result { - match value { - None | Some(JsonValue::Null) => Ok(Bytes::default()), - Some(JsonValue::String(v)) => Ok(Bytes::from(parse_hex_bytes(v, "extraData")?)), - _ => bail!("extraData must be null or hex string"), - } -} - -fn build_flags(sequence: U256, pay_out: bool, pay_in: bool, reputation: bool) -> Result { - if sequence > (U256::MAX >> 64) { - bail!("sequence is too large, must fit within 192 bits"); - } - let mut flags = sequence << 64; - if pay_out { - flags += U256::from(1u64); - } - if pay_in { - flags += U256::from(PAY_IN_FLAG); - } - if reputation { - flags += U256::from(4u64); - } - Ok(flags) -} - -fn should_pay_in(flags: U256) -> bool { - (flags & U256::from(PAY_IN_FLAG)) == U256::from(PAY_IN_FLAG) -} - -fn requires_token_approval(sender: EvmAddress, escrow: &SwapEscrowData) -> bool { - let native_token = EvmAddress::ZERO; - should_pay_in(escrow.flags) && escrow.offerer == sender && escrow.token != native_token -} - -fn compute_initialize_value_wei(sender: EvmAddress, escrow: &SwapEscrowData) -> U256 { - let native_token = EvmAddress::ZERO; - let mut value = U256::ZERO; - if should_pay_in(escrow.flags) && escrow.offerer == sender && escrow.token == native_token { - value += escrow.amount; - } - if escrow.deposit_token == native_token { - let total_deposit = if escrow.security_deposit > escrow.claimer_bounty { - escrow.security_deposit - } else { - escrow.claimer_bounty - }; - value += total_deposit; - } - value -} - -async fn ensure_token_approval( - goat_client: &GOATClient, - sender: EvmAddress, - swap_contract: EvmAddress, - escrow: &SwapEscrowData, -) -> Result<()> { - if !requires_token_approval(sender, escrow) { - return Ok(()); - } - - let owner = sender.into_array(); - let spender = swap_contract.into_array(); - let allowance = goat_client - .peg_btc_allowance(&owner, &spender) - .await - .context("failed to query token allowance before swap initialize")?; - if allowance >= escrow.amount { - eprintln!( - "token allowance already sufficient, allowance={}, required={}", - allowance, escrow.amount - ); - return Ok(()); - } - - eprintln!( - "token allowance insufficient, approving token spend now, allowance={}, required={}", - allowance, escrow.amount - ); - let approve_tx_hash = goat_client - .peg_btc_approve(&spender, escrow.amount) - .await - .context("failed to send token approve tx before swap initialize")?; - eprintln!("token approve submitted: {approve_tx_hash}"); - - let latest_allowance = goat_client - .peg_btc_allowance(&owner, &spender) - .await - .context("failed to re-check token allowance after approve")?; - if latest_allowance < escrow.amount { - bail!( - "token allowance still insufficient after approve, allowance={}, required={}", - latest_allowance, - escrow.amount - ); - } - eprintln!("token allowance after approve: {}, required={}", latest_allowance, escrow.amount); - Ok(()) -} - -fn resolve_escrow_hash_input( - escrow_hash: Option, - swap_init_tx_hash: Option, -) -> Result { - match (escrow_hash, swap_init_tx_hash) { - (Some(escrow_hash), _) => { - Ok(EscrowHashInput::Provided(normalize_32byte_hex(&escrow_hash, "escrow_hash")?)) - } - (None, Some(tx_hash)) => Ok(EscrowHashInput::FromSwapInitTx(normalize_32byte_hex( - &tx_hash, - "swap_init_tx_hash", - )?)), - (None, None) => { - bail!("one of --escrow-hash or --swap-init-tx-hash must be provided") - } - } -} - -async fn derive_escrow_hash_from_swap_init_tx( - swap_init_tx_hash: &str, - contract_address: &str, -) -> Result { - let swap_contract_address = EvmAddress::from_str(contract_address) - .map_err(|e| anyhow!("invalid swap contract address {contract_address}: {e}"))?; - let goat_client = GOATClient::new(goat_config_from_env().await, get_goat_network()); - let escrow_hash = goat_client - .extract_initialize_escrow_hash_from_tx(swap_init_tx_hash, swap_contract_address) - .await - .with_context(|| format!("failed to parse initialize log from tx {swap_init_tx_hash}"))?; - escrow_hash.ok_or_else(|| { - anyhow!( - "Initialize event with escrowHash not found in tx {swap_init_tx_hash} logs for contract {contract_address}" - ) - }) -} - -async fn submit_swap_initialize_tx( - goat_private_key: Option, - contract_address: Option, - max_wait_secs: u64, - escrow: SwapEscrowData, - signature: Bytes, - timeout: U256, - extra_data: Bytes, -) -> Result<(String, String)> { - let contract_address = resolve_contract_address(contract_address)?; - let swap_contract = EvmAddress::from_str(&contract_address) - .map_err(|e| anyhow!("invalid swap contract address {contract_address}: {e}"))?; - - let mut cfg = goat_config_from_env().await; - if goat_private_key.is_none() && cfg.private_key.is_none() { - bail!("missing GOAT private key (--goat-private-key or GOAT_PRIVATE_KEY)"); - } - if goat_private_key.is_some() { - cfg = cfg.with_private_key(goat_private_key); - } - cfg = cfg.with_peg_btc_address(Some(escrow.token)); - let goat_client = GOATClient::new(cfg, get_goat_network()); - let sender = goat_client.get_default_signer_address(); - ensure_token_approval(&goat_client, sender, swap_contract, &escrow).await?; - let value_wei = compute_initialize_value_wei(sender, &escrow); - let result = goat_client - .swap_initialize( - swap_contract, - escrow, - signature, - timeout, - extra_data, - value_wei, - max_wait_secs, - ) - .await?; - Ok((result.tx_hash, result.escrow_hash)) -} - -async fn call_pay_invoice( - client: &reqwest::Client, - args: &Commands, -) -> Result<(PayInvoiceResponseData, Option, Option, u64)> { - let Commands::SwapInitialize { - pay_invoice_url, - btc_address, - amount, - exact_in, - confirmation_target, - confirmations, - token, - offerer, - additional_params_json, - goat_private_key, - contract_address, - max_wait_secs, - } = args - else { - bail!("invalid command for call_pay_invoice"); - }; - - let (nonce, fee_rate) = generate_default_nonce_and_fee_rate().await?; - let amount_base_units = convert_pegbtc_amount_to_base_units(amount)?; - eprintln!("auto-generated nonce: {nonce}"); - eprintln!("auto-generated feeRate: {fee_rate}"); - eprintln!("converted amount (pegBTC -> base units): {amount_base_units}"); - - let mut body = serde_json::Map::new(); - if let Some(params_json) = additional_params_json { - body.extend(parse_json_object(params_json, "additional_params_json")?); - } - body.insert("address".to_string(), JsonValue::String(btc_address.clone())); - body.insert("amount".to_string(), JsonValue::String(amount_base_units)); - body.insert("exactIn".to_string(), JsonValue::Bool(*exact_in)); - body.insert("confirmationTarget".to_string(), JsonValue::from(*confirmation_target)); - body.insert("confirmations".to_string(), JsonValue::from(*confirmations)); - body.insert("nonce".to_string(), JsonValue::String(nonce)); - body.insert("token".to_string(), JsonValue::String(token.clone())); - body.insert("offerer".to_string(), JsonValue::String(offerer.clone())); - body.insert("feeRate".to_string(), JsonValue::String(fee_rate)); - - let resp = client - .post(pay_invoice_url) - .json(&JsonValue::Object(body)) - .send() - .await - .with_context(|| format!("failed to reach payInvoice API at {pay_invoice_url}"))?; - - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if !status.is_success() { - if let Some(api_err) = parse_api_error(&text) { - bail!("payInvoice API error ({}): {}", api_err.error, api_err.message); - } - bail!("payInvoice API returned {status}: {text}"); - } - - let envelope: PayInvoiceEnvelope = - serde_json::from_str(&text).context("failed to parse payInvoice response body")?; - if envelope.code != 20000 { - bail!("payInvoice business error (code {}): {}", envelope.code, envelope.msg); - } - - let data = envelope.data.ok_or_else(|| anyhow!("payInvoice response missing data"))?; - - Ok((data, goat_private_key.clone(), contract_address.clone(), *max_wait_secs)) -} - -fn parse_escrow_from_pay_invoice( - quote: &PayInvoiceResponseData, -) -> Result<(SwapEscrowData, Bytes, U256, Bytes)> { - let escrow_raw = "e.data; - let sequence = parse_u256_from_json_value(&escrow_raw.sequence, "sequence")?; - let flags = - build_flags(sequence, escrow_raw.pay_out, escrow_raw.pay_in, escrow_raw.reputation)?; - - let success_action_commitment = - escrow_raw.success_action_commitment.as_deref().unwrap_or(ZERO_B256_HEX); - let escrow = SwapEscrowData { - offerer: EvmAddress::from_str(&escrow_raw.offerer) - .map_err(|e| anyhow!("invalid offerer address {}: {e}", escrow_raw.offerer))?, - claimer: EvmAddress::from_str(&escrow_raw.claimer) - .map_err(|e| anyhow!("invalid claimer address {}: {e}", escrow_raw.claimer))?, - amount: parse_u256_from_json_value(&escrow_raw.amount, "amount")?, - token: EvmAddress::from_str(&escrow_raw.token) - .map_err(|e| anyhow!("invalid token address {}: {e}", escrow_raw.token))?, - flags, - claim_handler: EvmAddress::from_str(&escrow_raw.claim_handler).map_err(|e| { - anyhow!("invalid claimHandler address {}: {e}", escrow_raw.claim_handler) - })?, - claim_data: parse_bytes32(&escrow_raw.claim_data, "claimData")?, - refund_handler: EvmAddress::from_str(&escrow_raw.refund_handler).map_err(|e| { - anyhow!("invalid refundHandler address {}: {e}", escrow_raw.refund_handler) - })?, - refund_data: parse_bytes32(&escrow_raw.refund_data, "refundData")?, - security_deposit: parse_u256_from_json_value( - &escrow_raw.security_deposit, - "securityDeposit", - )?, - claimer_bounty: parse_u256_from_json_value(&escrow_raw.claimer_bounty, "claimerBounty")?, - deposit_token: EvmAddress::from_str(&escrow_raw.deposit_token).map_err(|e| { - anyhow!("invalid depositToken address {}: {e}", escrow_raw.deposit_token) - })?, - success_action_commitment: parse_bytes32( - success_action_commitment, - "successActionCommitment", - )?, - }; - let signature = Bytes::from(parse_hex_bytes("e.signature, "signature")?); - let timeout = parse_u256("e.timeout, "timeout")?; - let extra_data = parse_extra_data_from_json(escrow_raw.extra_data.as_ref())?; - Ok((escrow, signature, timeout, extra_data)) -} - -async fn run_swap_initialize_from_pay_invoice( - client: &reqwest::Client, - args: &Commands, -) -> Result<(String, String)> { - let (quote, goat_private_key, contract_address, max_wait_secs) = - call_pay_invoice(client, args).await?; - eprintln!("payInvoice prefix: {}", quote.prefix); - let (escrow, signature, timeout, extra_data) = parse_escrow_from_pay_invoice("e)?; - submit_swap_initialize_tx( - goat_private_key, - contract_address, - max_wait_secs, - escrow, - signature, - timeout, - extra_data, - ) - .await -} - -async fn call_bridge_out_init_tag( - client: &reqwest::Client, - base_url: &str, - body: &BridgeOutInitTagApiRequest, -) -> Result<()> { - let url = bridge_out_init_tag_url(base_url); - let resp = client - .put(&url) - .json(body) - .send() - .await - .with_context(|| format!("failed to reach node API at {url}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if let Some(api_err) = parse_api_error(&text) { - bail!("API error ({}): {}", api_err.error, api_err.message); - } - bail!("API returned {status}: {text}"); - } - - Ok(()) -} - -async fn call_get_escrow_data( - client: &reqwest::Client, - base_url: &str, - instance_id: &Uuid, -) -> Result { - let url = escrow_data_url(base_url, instance_id); - let resp = client - .get(&url) - .send() - .await - .with_context(|| format!("failed to reach node API at {url}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if let Some(api_err) = parse_api_error(&text) { - bail!("API error ({}): {}", api_err.error, api_err.message); - } - bail!("API returned {status}: {text}"); - } - - resp.json().await.context("failed to parse escrow data response") -} - -#[tokio::main] -async fn main() -> Result<()> { - dotenv::dotenv().ok(); - let args = Args::parse(); - let client = reqwest::Client::new(); - - match &args.command { - Commands::InitTag { - from_addr, - to_addr, - escrow_hash, - swap_init_tx_hash, - contract_address, - } => { - let contract_address = resolve_contract_address(contract_address.clone())?; - if escrow_hash.is_some() && swap_init_tx_hash.is_some() { - eprintln!( - "both --escrow-hash and --swap-init-tx-hash provided, using --escrow-hash" - ); - } - let escrow_hash = - match resolve_escrow_hash_input(escrow_hash.clone(), swap_init_tx_hash.clone())? { - EscrowHashInput::Provided(escrow_hash) => escrow_hash, - EscrowHashInput::FromSwapInitTx(swap_init_tx_hash) => { - let tx_hash = - normalize_32byte_hex(&swap_init_tx_hash, "swap_init_tx_hash")?; - let derived = - derive_escrow_hash_from_swap_init_tx(&tx_hash, &contract_address) - .await?; - eprintln!("derived escrow hash from swap init tx {tx_hash}: {derived}"); - derived - } - }; - - let body = BridgeOutInitTagApiRequest { - contract_address, - from_addr: from_addr.clone(), - to_addr: to_addr.clone(), - escrow_hash, - }; - - call_bridge_out_init_tag(&client, &args.rpc_url, &body).await?; - println!("bridge-out init-tag submitted successfully"); - } - Commands::EscrowData { instance_id } => { - let resp = call_get_escrow_data(&client, &args.rpc_url, instance_id).await?; - println!("instance_id: {}", resp.instance_id); - println!("escrow: {}", resp.escrow.as_deref().unwrap_or("null")); - println!("error: {}", resp.error.as_deref().unwrap_or("null")); - } - Commands::SwapInitialize { .. } => { - let (tx_hash, escrow_hash) = - run_swap_initialize_from_pay_invoice(&client, &args.command).await?; - println!("swap initialize submitted: {tx_hash}"); - println!("escrow_hash (from Initialize log): {escrow_hash}"); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn test_bridge_out_init_tag_url() { - assert_eq!( - bridge_out_init_tag_url("http://localhost:8080/"), - "http://localhost:8080/v1/instances/bridge-out-init-tag" - ); - } - - #[test] - fn test_escrow_data_url() { - let instance_id = Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").unwrap(); - assert_eq!( - escrow_data_url("http://localhost:8080", &instance_id), - "http://localhost:8080/v1/instances/123e4567-e89b-12d3-a456-426614174000/escrow-data" - ); - } - - #[test] - fn test_parse_api_error() { - let body = r#"{"error":"PUT_BRIDGE_OUT_INIT_TAG_ERROR","message":"invalid"}"#; - let parsed = parse_api_error(body); - assert!(parsed.is_some()); - let parsed = parsed.unwrap(); - assert_eq!(parsed.error, "PUT_BRIDGE_OUT_INIT_TAG_ERROR"); - assert_eq!(parsed.message, "invalid"); - } - - #[test] - fn test_resolve_contract_address_arg_priority() { - let _guard = ENV_LOCK.lock().unwrap(); - unsafe { - std::env::set_var( - ENV_GOAT_SWAP_CONTRACT_ADDRESS, - "0x2222222222222222222222222222222222222222", - ); - } - let value = resolve_contract_address(Some( - "0x1111111111111111111111111111111111111111".to_string(), - )) - .unwrap(); - assert_eq!(value, "0x1111111111111111111111111111111111111111"); - } - - #[test] - fn test_resolve_contract_address_from_env() { - let _guard = ENV_LOCK.lock().unwrap(); - unsafe { - std::env::set_var( - ENV_GOAT_SWAP_CONTRACT_ADDRESS, - "0x3333333333333333333333333333333333333333", - ); - } - let value = resolve_contract_address(None).unwrap(); - assert_eq!(value, "0x3333333333333333333333333333333333333333"); - } - - #[test] - fn test_resolve_contract_address_missing() { - let _guard = ENV_LOCK.lock().unwrap(); - unsafe { - std::env::remove_var(ENV_GOAT_SWAP_CONTRACT_ADDRESS); - } - let err = resolve_contract_address(None).unwrap_err().to_string(); - assert!(err.contains("GOAT_SWAP_CONTRACT_ADDRESS")); - } - - #[test] - fn test_resolve_escrow_hash_input_uses_provided() { - let input = resolve_escrow_hash_input( - Some(format!("0x{}", "11".repeat(32))), - Some(format!("0x{}", "22".repeat(32))), - ) - .unwrap(); - match input { - EscrowHashInput::Provided(v) => assert_eq!(v, format!("0x{}", "11".repeat(32))), - EscrowHashInput::FromSwapInitTx(_) => panic!("expected provided escrow hash"), - } - } - - #[test] - fn test_resolve_escrow_hash_input_from_tx() { - let input = - resolve_escrow_hash_input(None, Some(format!("0x{}", "aa".repeat(32)))).unwrap(); - match input { - EscrowHashInput::FromSwapInitTx(v) => assert_eq!(v, format!("0x{}", "aa".repeat(32))), - EscrowHashInput::Provided(_) => panic!("expected tx hash source"), - } - } - - #[test] - fn test_resolve_escrow_hash_input_missing() { - let err = resolve_escrow_hash_input(None, None).unwrap_err().to_string(); - assert!(err.contains("--escrow-hash")); - } - - #[test] - fn test_resolve_escrow_hash_input_invalid_len() { - let err = - resolve_escrow_hash_input(Some("0x12".to_string()), None).unwrap_err().to_string(); - assert!(err.contains("exactly 32 bytes")); - } - - #[test] - fn test_parse_u256_decimal_and_hex() { - assert_eq!(parse_u256("10", "amount").unwrap(), U256::from(10u64)); - assert_eq!(parse_u256("0x0a", "amount").unwrap(), U256::from(10u64)); - } - - #[test] - fn test_parse_u256_from_json_value() { - assert_eq!( - parse_u256_from_json_value(&JsonValue::String("10".to_string()), "amount").unwrap(), - U256::from(10u64) - ); - assert_eq!( - parse_u256_from_json_value(&JsonValue::from(15u64), "amount").unwrap(), - U256::from(15u64) - ); - } - - #[test] - fn test_build_flags() { - let flags = build_flags(U256::from(7u64), true, true, false).unwrap(); - let expected = (U256::from(7u64) << 64) + U256::from(3u64); - assert_eq!(flags, expected); - assert!(should_pay_in(flags)); - } - - #[test] - fn test_generate_default_nonce() { - let nonce = generate_default_nonce(700_000_001, 0x12_34_56); - let expected = ((1u64 << 24) | 0x12_34_56u64).to_string(); - assert_eq!(nonce, expected); - } - - #[test] - fn test_generate_default_nonce_masks_high_bits() { - let nonce = generate_default_nonce(700_000_001, 0xab_cd_ef_12); - let expected = ((1u64 << 24) | 0xcd_ef_12u64).to_string(); - assert_eq!(nonce, expected); - } - - #[test] - fn test_generate_default_fee_rate() { - let fee_rate = generate_default_fee_rate(U256::from(100u64)); - assert_eq!(fee_rate, "125,5000000"); - } - - #[test] - fn test_generate_default_fee_rate_with_cap() { - let fee_rate = generate_default_fee_rate(U256::from(1_000_000_000_000u64)); - assert_eq!(fee_rate, "2000000000,5000000"); - } - - #[test] - fn test_convert_pegbtc_amount_to_base_units() { - assert_eq!(convert_pegbtc_amount_to_base_units("0.01").unwrap(), "10000000000000000"); - assert_eq!(convert_pegbtc_amount_to_base_units("1").unwrap(), "1000000000000000000"); - assert_eq!(convert_pegbtc_amount_to_base_units(".5").unwrap(), "500000000000000000"); - assert_eq!(convert_pegbtc_amount_to_base_units("0").unwrap(), "0"); - } - - #[test] - fn test_convert_pegbtc_amount_to_base_units_invalid_precision() { - let err = - convert_pegbtc_amount_to_base_units("0.1234567890123456789").unwrap_err().to_string(); - assert!(err.contains("too many decimal places")); - } - - #[test] - fn test_compute_initialize_value_wei() { - let offerer = EvmAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(); - let claimer = EvmAddress::from_str("0x2222222222222222222222222222222222222222").unwrap(); - let escrow = SwapEscrowData { - offerer, - claimer, - amount: U256::from(100u64), - token: EvmAddress::ZERO, - flags: U256::from(PAY_IN_FLAG), - claim_handler: EvmAddress::from_str("0x3333333333333333333333333333333333333333") - .unwrap(), - claim_data: [0u8; 32], - refund_handler: EvmAddress::from_str("0x4444444444444444444444444444444444444444") - .unwrap(), - refund_data: [0u8; 32], - security_deposit: U256::from(8u64), - claimer_bounty: U256::from(5u64), - deposit_token: EvmAddress::ZERO, - success_action_commitment: [0u8; 32], - }; - assert_eq!(compute_initialize_value_wei(offerer, &escrow), U256::from(108u64)); - } - - #[test] - fn test_requires_token_approval_true_for_payin_erc20_offerer() { - let sender = EvmAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(); - let escrow = SwapEscrowData { - offerer: sender, - claimer: EvmAddress::from_str("0x2222222222222222222222222222222222222222").unwrap(), - amount: U256::from(100u64), - token: EvmAddress::from_str("0x3333333333333333333333333333333333333333").unwrap(), - flags: U256::from(PAY_IN_FLAG), - claim_handler: EvmAddress::from_str("0x4444444444444444444444444444444444444444") - .unwrap(), - claim_data: [0u8; 32], - refund_handler: EvmAddress::from_str("0x5555555555555555555555555555555555555555") - .unwrap(), - refund_data: [0u8; 32], - security_deposit: U256::ZERO, - claimer_bounty: U256::ZERO, - deposit_token: EvmAddress::ZERO, - success_action_commitment: [0u8; 32], - }; - assert!(requires_token_approval(sender, &escrow)); - } - - #[test] - fn test_requires_token_approval_false_for_native_or_non_payin() { - let sender = EvmAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(); - let mut escrow = SwapEscrowData { - offerer: sender, - claimer: EvmAddress::from_str("0x2222222222222222222222222222222222222222").unwrap(), - amount: U256::from(100u64), - token: EvmAddress::ZERO, - flags: U256::from(PAY_IN_FLAG), - claim_handler: EvmAddress::from_str("0x4444444444444444444444444444444444444444") - .unwrap(), - claim_data: [0u8; 32], - refund_handler: EvmAddress::from_str("0x5555555555555555555555555555555555555555") - .unwrap(), - refund_data: [0u8; 32], - security_deposit: U256::ZERO, - claimer_bounty: U256::ZERO, - deposit_token: EvmAddress::ZERO, - success_action_commitment: [0u8; 32], - }; - assert!(!requires_token_approval(sender, &escrow)); - - escrow.token = EvmAddress::from_str("0x3333333333333333333333333333333333333333").unwrap(); - escrow.flags = U256::ZERO; - assert!(!requires_token_approval(sender, &escrow)); - } -} diff --git a/node/src/env.rs b/node/src/env.rs index 97011bf21..adb399bc4 100644 --- a/node/src/env.rs +++ b/node/src/env.rs @@ -61,6 +61,11 @@ pub const ENV_ACTOR: &str = "ACTOR"; pub const ENV_IPFS_ENDPOINT: &str = "IPFS_ENDPOINT"; pub const ENV_COMMITTEE_NUM: &str = "COMMITTEE_NUM"; pub const ENV_MIN_REQUIRED_VERIFIER: &str = "MIN_REQUIRED_VERIFIER"; +pub const ENV_VERIFIER_CANDIDATE_BACKUP_COUNT: &str = "VERIFIER_CANDIDATE_BACKUP_COUNT"; +pub const ENV_VERIFIER_CANDIDATE_COLLECTION_WINDOW_SECS: &str = + "VERIFIER_CANDIDATE_COLLECTION_WINDOW_SECS"; +pub const ENV_P2P_GRAPH_SETUP_RETRY_INTERVAL_SECS: &str = "P2P_GRAPH_SETUP_RETRY_INTERVAL_SECS"; +pub const ENV_P2P_GRAPH_SETUP_RETRY_WINDOW_SECS: &str = "P2P_GRAPH_SETUP_RETRY_WINDOW_SECS"; pub const ENV_EXTERNAL_SOCKET_ADDR: &str = "EXTERNAL_SOCKET_ADDR"; pub const COMMITTEE_INSTANCE_KEYS_DIR: &str = "cache/committee-instance-keys/"; pub const SCRIPT_CACHE_FILE_NAME: &str = "cache/partial_script.bin"; @@ -71,6 +76,10 @@ pub const MAX_CUSTOM_INPUTS: usize = 100; pub const DEFAULT_CONFIRMATION_TARGET: u16 = 1; pub const DEFAULT_MIN_REQUIRED_VERIFIER: usize = 1; +pub const DEFAULT_VERIFIER_CANDIDATE_BACKUP_COUNT: usize = 2; +pub const DEFAULT_VERIFIER_CANDIDATE_COLLECTION_WINDOW_SECS: i64 = 15; +pub const DEFAULT_P2P_GRAPH_SETUP_RETRY_INTERVAL_SECS: i64 = 10; +pub const DEFAULT_P2P_GRAPH_SETUP_RETRY_WINDOW_SECS: i64 = 180; pub const ENV_BITCOIN_NETWORK: &str = "BITCOIN_NETWORK"; pub const ENV_GOAT_NETWORK: &str = "GOAT_NETWORK"; @@ -589,6 +598,37 @@ pub fn get_min_required_verifier() -> anyhow::Result { } } +pub fn get_verifier_candidate_backup_count() -> usize { + std::env::var(ENV_VERIFIER_CANDIDATE_BACKUP_COUNT) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_VERIFIER_CANDIDATE_BACKUP_COUNT) +} + +pub fn get_verifier_candidate_collection_window_secs() -> i64 { + std::env::var(ENV_VERIFIER_CANDIDATE_COLLECTION_WINDOW_SECS) + .ok() + .and_then(|value| value.parse::().ok()) + .map(|value| value.clamp(1, 300)) + .unwrap_or(DEFAULT_VERIFIER_CANDIDATE_COLLECTION_WINDOW_SECS) +} + +pub fn get_p2p_graph_setup_retry_interval_secs() -> i64 { + std::env::var(ENV_P2P_GRAPH_SETUP_RETRY_INTERVAL_SECS) + .ok() + .and_then(|value| value.parse::().ok()) + .map(|value| value.clamp(1, 300)) + .unwrap_or(DEFAULT_P2P_GRAPH_SETUP_RETRY_INTERVAL_SECS) +} + +pub fn get_p2p_graph_setup_retry_window_secs() -> i64 { + std::env::var(ENV_P2P_GRAPH_SETUP_RETRY_WINDOW_SECS) + .ok() + .and_then(|value| value.parse::().ok()) + .map(|value| value.clamp(10, 3600)) + .unwrap_or(DEFAULT_P2P_GRAPH_SETUP_RETRY_WINDOW_SECS) +} + pub fn get_soldering_proof_payload_store_path() -> anyhow::Result { let value = std::env::var(ENV_SOLDERING_PROOF_PAYLOAD_STORE_PATH) .map_err(|_| anyhow::anyhow!("{ENV_SOLDERING_PROOF_PAYLOAD_STORE_PATH} needs to be set"))?; diff --git a/node/src/handle.rs b/node/src/handle.rs index 10a61c095..ecd744e35 100644 --- a/node/src/handle.rs +++ b/node/src/handle.rs @@ -1,11 +1,13 @@ use crate::action::*; use crate::env::{ COMMITTEE_INSTANCE_KEYS_DIR, get_babe_gc_asset_paths, get_bitvm_key, get_node_goat_address, - get_soldering_proof_payload_store_path, is_relayer, + get_peer_id, get_soldering_proof_payload_store_path, get_verifier_candidate_backup_count, + get_verifier_candidate_collection_window_secs, is_relayer, }; use crate::error::SpecialError; use crate::metrics_service::MetricsState; use crate::middleware::AllBehaviours; +use crate::rpc_service::current_time_secs; use crate::scheduled_tasks::graph_maintenance_tasks::ChallengeSubStatus; use crate::soldering_payload_store::{ read_soldering_proof_store_payload, soldering_proof_payload_store_path, @@ -13,7 +15,7 @@ use crate::soldering_payload_store::{ use crate::utils::*; use anyhow::{Context, Result, anyhow, bail}; use ark_serialize::CanonicalSerialize; -use bitcoin::{Amount, OutPoint, Txid, hashes::Hash}; +use bitcoin::{Amount, OutPoint, Txid, hashes::Hash, key::Keypair}; use bitcoin::{PublicKey, XOnlyPublicKey}; use bitvm_lib::actors::Actor; use bitvm_lib::babe_adapter::{ @@ -39,6 +41,7 @@ use goat::transactions::pre_signed_musig2::verify_public_nonce; use goat::wots::{Wots, Wots96}; use libp2p::gossipsub::MessageId; use libp2p::{PeerId, Swarm}; +use std::str::FromStr; use std::sync::Arc; use std::time::Instant; use store::localdb::LocalDB; @@ -140,7 +143,7 @@ pub(crate) fn is_heavy_task_message_type(message_type: &str, actor: &Actor) -> b pub(crate) async fn run_heavy_task(context: &HeavyTaskContext, task: HeavyTask) -> Result<()> { match task { HeavyTask::GenerateVerifierSetup(message) => { - handle_init_graph_verifier(context, message.instance_id, message.graph_id).await + handle_init_graph_verifier(context, message).await } HeavyTask::GenerateSolderingProof(message) => { handle_cut_circuits_verifier( @@ -148,7 +151,7 @@ pub(crate) async fn run_heavy_task(context: &HeavyTaskContext, task: HeavyTask) message.instance_id, message.graph_id, &message.verifier_pubkey, - message.verifier_index, + message.candidate_index, &message.selected_circuit_indexes, ) .await @@ -168,7 +171,7 @@ pub(crate) async fn run_heavy_task(context: &HeavyTaskContext, task: HeavyTask) context, message.instance_id, message.graph_id, - message.verifier_index, + message.candidate_index, message.payload_hash, message.total_len, ) @@ -238,6 +241,37 @@ fn load_or_create_committee_instance_keypair( pub async fn dispatch(ctx: &mut HandlerContext<'_>, content: &GOATMessageContent) -> Result<()> { match (content, &ctx.actor) { + ( + GOATMessageContent::GraphSetupAck(GraphSetupAck { + outbox_id, + acknowledger_peer_id, + .. + }), + _, + ) => { + if acknowledger_peer_id != &ctx.from_peer_id.to_string() { + tracing::warn!( + outbox_id, + from_peer_id = %ctx.from_peer_id, + acknowledger_peer_id, + "Ignore GraphSetupAck with mismatched source peer" + ); + return Ok(()); + } + let acknowledged = ctx + .local_db + .acquire() + .await? + .acknowledge_p2p_outbox_message(outbox_id, acknowledger_peer_id) + .await?; + tracing::debug!( + outbox_id, + from_peer_id = %ctx.from_peer_id, + acknowledged, + "processed GraphSetupAck" + ); + Ok(()) + } ( GOATMessageContent::PeginRequest(PeginRequest { instance_id, @@ -740,6 +774,17 @@ fn make_message(ctx: &HandlerContext<'_>, content: &GOATMessageContent) -> GOATM GOATMessage::new(ctx.actor.clone(), content.clone()) } +fn build_signed_init_graph( + instance_id: Uuid, + graph_id: Uuid, + operator_keypair: &Keypair, +) -> Result { + let operator_peer_id = PeerId::from_str(&get_peer_id()) + .context("decode local operator peer id for InitGraph")? + .to_bytes(); + Ok(sign_init_graph(instance_id, graph_id, operator_keypair, operator_peer_id)) +} + /// A graph-bearing message has two identity representations: its envelope and /// the signed graph parameters. Never use one to read/write local state while /// using the other to reconstruct or scan the graph. @@ -766,9 +811,10 @@ fn message_identity_matches( false } -/// Freezes the first accepted Verifiers and assigns deterministic graph slots. +/// Closes a bounded candidate collection window and assigns provisional slots. +/// Final graph slots are assigned only after valid soldering proofs are collected. fn freeze_operator_candidates(state: &mut OperatorBabeSetupState) -> Result<()> { - if state.frozen_verifier_pubkeys.is_some() { + if state.candidate_verifier_pubkeys.is_some() { return Ok(()); } if state.candidates.len() < min_required_verifier() { @@ -784,38 +830,92 @@ fn freeze_operator_candidates(state: &mut OperatorBabeSetupState) -> Result<()> bail!("cannot freeze duplicate verifier peer id"); } } - state.candidates.truncate(min_required_verifier()); state.candidates.sort_by_key(|candidate| candidate.verifier_pubkey.to_bytes()); - for (verifier_index, candidate) in state.candidates.iter_mut().enumerate() { - candidate.verifier_index = Some(verifier_index); + for (candidate_index, candidate) in state.candidates.iter_mut().enumerate() { + candidate.candidate_index = Some(candidate_index); candidate.selected_circuit_indexes = derive_finalized_indices(&candidate.setup_package, BABE_M_CC)?; } - state.frozen_verifier_pubkeys = + state.candidate_verifier_pubkeys = Some(state.candidates.iter().map(|candidate| candidate.verifier_pubkey).collect()); Ok(()) } -/// Stores one selected Verifier slot and emits ordered graph data when complete. +fn seal_selected_verifiers( + state: &mut OperatorBabeSetupState, + selected_verifier_pubkeys: Vec, +) -> Result<()> { + if let Some(existing) = &state.selected_verifier_pubkeys { + if existing != &selected_verifier_pubkeys { + bail!("refuse to replace the sealed verifier selection"); + } + return Ok(()); + } + if selected_verifier_pubkeys.len() != min_required_verifier() { + bail!( + "selected verifier count {} does not match required {}", + selected_verifier_pubkeys.len(), + min_required_verifier() + ); + } + if selected_verifier_pubkeys.windows(2).any(|pair| pair[0].to_bytes() >= pair[1].to_bytes()) { + bail!("selected verifier public keys must be unique and canonically ordered"); + } + if selected_verifier_pubkeys.iter().any(|selected| { + !state.candidates.iter().any(|candidate| candidate.verifier_pubkey == *selected) + }) { + bail!("sealed verifier selection contains a non-candidate public key"); + } + state.selected_verifier_pubkeys = Some(selected_verifier_pubkeys); + Ok(()) +} + +fn selected_gc_data(state: &OperatorBabeSetupState) -> Result> { + let selected = state + .selected_verifier_pubkeys + .as_ref() + .ok_or_else(|| anyhow!("verifier selection is not sealed"))?; + selected + .iter() + .map(|verifier_pubkey| { + state + .candidates + .iter() + .find(|candidate| candidate.verifier_pubkey == *verifier_pubkey) + .and_then(|candidate| candidate.gc_data.clone()) + .ok_or_else(|| { + anyhow!("missing verified GC data for sealed verifier {verifier_pubkey}") + }) + }) + .collect() +} + +fn selected_candidate_for_graph_index( + state: &OperatorBabeSetupState, + verifier_index: usize, +) -> Result<&OperatorVerifierCandidate> { + let verifier_pubkey = state + .selected_verifier_pubkeys + .as_ref() + .and_then(|selected| selected.get(verifier_index)) + .ok_or_else(|| anyhow!("missing sealed verifier slot {verifier_index}"))?; + state + .candidates + .iter() + .find(|candidate| candidate.verifier_pubkey == *verifier_pubkey) + .ok_or_else(|| anyhow!("missing candidate for sealed verifier slot {verifier_index}")) +} + +/// Stores verified proof data against its immutable candidate slot. fn record_candidate_gc_data( state: &mut OperatorBabeSetupState, verifier_pubkey: PublicKey, - verifier_index: usize, + candidate_index: usize, setup_package: &CACSetupPackage, gc_data: BitvmGcCircuitData, prover_state: &BabeProverState, soldering_proof_ready: SolderingProofReady, -) -> Result>> { - let frozen = state - .frozen_verifier_pubkeys - .as_ref() - .ok_or_else(|| anyhow!("operator verifier membership is not frozen"))?; - let expected_pubkey = frozen - .get(verifier_index) - .ok_or_else(|| anyhow!("verifier index {verifier_index} out of frozen slot range"))?; - if expected_pubkey != &verifier_pubkey { - bail!("verifier public key does not own slot {verifier_index}"); - } +) -> Result<()> { if gc_data.verifier_pubkey != verifier_pubkey { bail!("GC slot owner does not match soldering proof verifier"); } @@ -824,8 +924,8 @@ fn record_candidate_gc_data( .iter_mut() .find(|candidate| candidate.verifier_pubkey == verifier_pubkey) .ok_or_else(|| anyhow!("selected verifier candidate is missing"))?; - if candidate.verifier_index != Some(verifier_index) { - bail!("candidate verifier index does not match soldering proof slot"); + if candidate.candidate_index != Some(candidate_index) { + bail!("candidate index does not match soldering proof slot"); } if candidate.setup_package != *setup_package { bail!("soldering proof setup package does not match selected verifier candidate"); @@ -836,8 +936,8 @@ fn record_candidate_gc_data( if candidate.selected_circuit_indexes != prover_state.soldering.finalized_indices { bail!("BABE prover state finalized indices do not match selected verifier cut"); } - if soldering_proof_ready.verifier_index != verifier_index { - bail!("soldering proof reference verifier index does not match selected verifier slot"); + if soldering_proof_ready.candidate_index != candidate_index { + bail!("soldering proof reference candidate index does not match selected verifier"); } if prover_state.finalized.len() != BABE_M_CC || prover_state.h_msgs.len() != BABE_M_CC { bail!("BABE prover state must contain exactly {BABE_M_CC} finalized instances and hashes"); @@ -848,12 +948,12 @@ fn record_candidate_gc_data( if let Some(existing) = &candidate.gc_data && existing != &gc_data { - bail!("conflicting GC slot received for selected verifier"); + bail!("conflicting GC data received for verifier candidate"); } if let Some(existing) = &candidate.soldering_proof_ready && existing != &soldering_proof_ready { - bail!("conflicting soldering proof reference received for selected verifier"); + bail!("conflicting soldering proof reference received for verifier candidate"); } if candidate.gc_data.is_none() { candidate.gc_data = Some(gc_data); @@ -861,10 +961,7 @@ fn record_candidate_gc_data( if candidate.soldering_proof_ready.is_none() { candidate.soldering_proof_ready = Some(soldering_proof_ready); } - if state.candidates.iter().any(|candidate| candidate.gc_data.is_none()) { - return Ok(None); - } - Ok(Some(state.candidates.iter().map(|candidate| candidate.gc_data.clone().unwrap()).collect())) + Ok(()) } fn build_babe_prover_state( @@ -1438,8 +1535,17 @@ async fn handle_confirm_instance_operator( return Ok(()); } tracing::info!("Resume pending graph setup for {instance_id}, graph_id: {graph_id}"); - let message_content = GOATMessageContent::InitGraph(InitGraph { instance_id, graph_id }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::Verifier, message_content)).await?; + let message_content = GOATMessageContent::InitGraph(build_signed_init_graph( + instance_id, + graph_id, + &operator_master_key.master_keypair(), + )?); + enqueue_graph_setup_outbox_message( + ctx.local_db, + GOATMessage::new(Actor::Verifier, message_content), + None, + ) + .await?; return Ok(()); } @@ -1483,26 +1589,74 @@ async fn handle_confirm_instance_operator( storage .upsert_pending_graph_init(&instance_id, &local_operator_pubkey.to_string(), &graph_id) .await?; - let message_content = GOATMessageContent::InitGraph(InitGraph { instance_id, graph_id }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::Verifier, message_content)).await?; + let message_content = GOATMessageContent::InitGraph(build_signed_init_graph( + instance_id, + graph_id, + &operator_master_key.master_keypair(), + )?); + enqueue_graph_setup_outbox_message( + ctx.local_db, + GOATMessage::new(Actor::Verifier, message_content), + None, + ) + .await?; Ok(()) } // Generate garbled circuits and enqueue GenCircuits without blocking the swarm. -#[tracing::instrument(level = "info", skip_all, fields(instance_id = %instance_id, graph_id = %graph_id))] -async fn handle_init_graph_verifier( - context: &HeavyTaskContext, - instance_id: Uuid, - graph_id: Uuid, -) -> Result<()> { +#[tracing::instrument(level = "info", skip_all, fields(instance_id = %message.instance_id, graph_id = %message.graph_id))] +async fn handle_init_graph_verifier(context: &HeavyTaskContext, message: InitGraph) -> Result<()> { + let InitGraph { instance_id, graph_id, operator_pubkey, operator_peer_id, .. } = &message; + if operator_peer_id != &context.from_peer_id.to_bytes() { + tracing::warn!( + instance_id = %instance_id, + graph_id = %graph_id, + from_peer_id = %context.from_peer_id, + "Ignore InitGraph whose signed operator peer id differs from its P2P source" + ); + return Ok(()); + } + if !verify_init_graph_signature(&message) { + tracing::warn!( + instance_id = %instance_id, + graph_id = %graph_id, + operator_pubkey = %operator_pubkey, + "Ignore InitGraph with invalid operator signature" + ); + return Ok(()); + } + if let Err(error) = validate_operator_stake(&context.goat_client, operator_pubkey).await { + tracing::warn!( + instance_id = %instance_id, + graph_id = %graph_id, + operator_pubkey = %operator_pubkey, + error = %error, + "Ignore InitGraph from an operator that does not meet stake requirements" + ); + return Ok(()); + } + let verifier_master_key = VerifierMasterKey::new(get_bitvm_key()?); let verifier_pubkey = verifier_master_key.master_keypair().public_key().into(); - let saved_verifier_state = load_babe_setup_state(&context.local_db, instance_id, graph_id)? + let saved_verifier_state = load_babe_setup_state(&context.local_db, *instance_id, *graph_id)? .and_then(|state| state.verifier) .filter(|state| state.verifier_pubkey == verifier_pubkey); let verifier_state = if let Some(saved) = saved_verifier_state { + if saved.operator_pubkey != *operator_pubkey || saved.operator_peer_id != *operator_peer_id + { + tracing::warn!( + instance_id = %instance_id, + graph_id = %graph_id, + expected_operator_peer = %PeerId::from_bytes(&saved.operator_peer_id) + .map(|peer_id| peer_id.to_string()) + .unwrap_or_else(|_| "invalid".to_owned()), + from_peer_id = %context.from_peer_id, + "Ignore InitGraph with a different authenticated operator identity for an existing verifier setup" + ); + return Ok(()); + } saved } else { get_babe_gc_asset_paths()?; @@ -1516,6 +1670,8 @@ async fn handle_init_graph_verifier( tracing::info!("Verifier setup done."); VerifierBabeSetupState { verifier_pubkey, + operator_pubkey: *operator_pubkey, + operator_peer_id: operator_peer_id.clone(), setup_package, private_state, finalized_indices: vec![], @@ -1524,23 +1680,23 @@ async fn handle_init_graph_verifier( }; let setup_package = verifier_state.setup_package.clone(); - update_babe_setup_state(&context.local_db, instance_id, graph_id, |state| { + let operator_peer_id = PeerId::from_bytes(&verifier_state.operator_peer_id) + .map(|peer_id| peer_id.to_string()) + .context("decode operator peer id saved from InitGraph")?; + update_babe_setup_state(&context.local_db, *instance_id, *graph_id, |state| { state.verifier = Some(verifier_state); })?; - let gen_circuits = GenCircuits { instance_id, graph_id, verifier_pubkey, setup_package }; - let outbox_id = format!( - "gen-circuits:{}:{}:{}", - gen_circuits.instance_id, gen_circuits.graph_id, gen_circuits.verifier_pubkey, - ); + let gen_circuits = GenCircuits { + instance_id: *instance_id, + graph_id: *graph_id, + verifier_pubkey, + setup_package, + }; let message = GOATMessage::new(Actor::Operator, GOATMessageContent::GenCircuits(gen_circuits)); - let serialized = message.serialize_message().await?; - context - .local_db - .acquire() - .await? - .enqueue_p2p_outbox_message(&outbox_id, message.content.event_type(), &serialized) - .await?; + let outbox_id = + enqueue_graph_setup_outbox_message(&context.local_db, message, Some(&operator_peer_id)) + .await?; tracing::info!( event = "verifier_gc_setup", outcome = "enqueued", @@ -1599,12 +1755,15 @@ async fn handle_gen_circuits_operator( let mut state = load_babe_setup_state(ctx.local_db, instance_id, graph_id)?.unwrap_or_default(); let operator_state = state.operator.get_or_insert_with(|| OperatorBabeSetupState { - frozen_verifier_pubkeys: None, + candidate_verifier_pubkeys: None, candidates: vec![], + candidate_collection_started_at: None, + proof_collection_started_at: None, + selected_verifier_pubkeys: None, asserted_operator_proof: None, }); - let was_frozen = operator_state.frozen_verifier_pubkeys.is_some(); + let was_frozen = operator_state.candidate_verifier_pubkeys.is_some(); if let Some(existing) = operator_state .candidates .iter() @@ -1636,49 +1795,74 @@ async fn handle_gen_circuits_operator( return Ok(()); } else { + if operator_state.candidates.len() + >= min_required_verifier().saturating_add(get_verifier_candidate_backup_count()) + { + tracing::debug!( + "Ignore GenCircuits for {instance_id}:{graph_id}: candidate collection reached its configured limit" + ); + return Ok(()); + } operator_state.candidates.push(OperatorVerifierCandidate { verifier_peer_id, verifier_pubkey: *verifier_pubkey, setup_package: setup_package.clone(), - verifier_index: None, + candidate_index: None, selected_circuit_indexes: vec![], gc_data: None, soldering_proof_ready: None, }); + operator_state.candidate_collection_started_at.get_or_insert_with(current_time_secs); + } + + if operator_state.candidate_verifier_pubkeys.is_none() { + let started_at = + operator_state.candidate_collection_started_at.get_or_insert_with(current_time_secs); + let elapsed = current_time_secs() - *started_at; + let window = get_verifier_candidate_collection_window_secs(); + let reached_limit = operator_state.candidates.len() + >= min_required_verifier().saturating_add(get_verifier_candidate_backup_count()); + if operator_state.candidates.len() < min_required_verifier() + || (!reached_limit && elapsed < window) + { + let retry_after_secs = (window - elapsed).max(1); + let candidate_count = operator_state.candidates.len(); + save_babe_setup_state(ctx.local_db, instance_id, graph_id, &state)?; + return Err(retryable_dispatch_error( + RetryableDispatchReason::DependencyPending, + Some(retry_after_secs), + format!( + "collecting verifier candidates: {}/{} received", + candidate_count, + min_required_verifier(), + ), + )); + } } - if operator_state.frozen_verifier_pubkeys.is_none() - && operator_state.candidates.len() < min_required_verifier() - { - save_babe_setup_state(ctx.local_db, instance_id, graph_id, &state)?; - - return Ok(()); + if operator_state.candidate_verifier_pubkeys.is_none() { + freeze_operator_candidates(operator_state)?; } - - freeze_operator_candidates(operator_state)?; - let cut_candidates = if was_frozen { - operator_state - .candidates - .iter() - .filter(|candidate| candidate.verifier_pubkey == *verifier_pubkey) - .cloned() - .collect::>() - } else { - operator_state.candidates.clone() - }; + let cut_candidates = if was_frozen { Vec::new() } else { operator_state.candidates.clone() }; save_babe_setup_state(ctx.local_db, instance_id, graph_id, &state)?; for candidate in cut_candidates { - let message_content = GOATMessageContent::CutCircuits(CutCircuits { - instance_id, - graph_id, - verifier_pubkey: candidate.verifier_pubkey, - verifier_index: candidate.verifier_index.unwrap(), - selected_circuit_indexes: candidate.selected_circuit_indexes, - }); - send_to_peer(ctx.swarm, GOATMessage::new(Actor::Verifier, message_content)).await?; + let message = GOATMessage::new( + Actor::Verifier, + GOATMessageContent::CutCircuits(CutCircuits { + instance_id, + graph_id, + verifier_pubkey: candidate.verifier_pubkey, + candidate_index: candidate + .candidate_index + .expect("candidate slot assigned before CutCircuits"), + selected_circuit_indexes: candidate.selected_circuit_indexes, + }), + ); + let ack_peer_id = + PeerId::from_bytes(&candidate.verifier_peer_id).map(|peer_id| peer_id.to_string()).ok(); + enqueue_graph_setup_outbox_message(ctx.local_db, message, ack_peer_id.as_deref()).await?; } - Ok(()) } @@ -1689,7 +1873,7 @@ async fn handle_cut_circuits_verifier( instance_id: Uuid, graph_id: Uuid, verifier_pubkey: &PublicKey, - verifier_index: usize, + candidate_index: usize, selected_circuit_indexes: &Vec, ) -> Result<()> { let verifier_master_key = VerifierMasterKey::new(get_bitvm_key()?); @@ -1718,18 +1902,42 @@ async fn handle_cut_circuits_verifier( return Ok(()); } + if verifier_state.operator_peer_id != context.from_peer_id.to_bytes() { + tracing::warn!( + instance_id = %instance_id, + graph_id = %graph_id, + from_peer_id = %context.from_peer_id, + "Ignore CutCircuits from a peer other than the operator that initiated verifier setup" + ); + return Ok(()); + } if let Some(soldering_proof_ready) = verifier_state.soldering_proof_ready.clone() { - if soldering_proof_ready.verifier_index != verifier_index { + if soldering_proof_ready.candidate_index != candidate_index { bail!( - "CutCircuits verifier index {verifier_index} conflicts with persisted slot {}", - soldering_proof_ready.verifier_index + "CutCircuits candidate index {candidate_index} conflicts with persisted slot {}", + soldering_proof_ready.candidate_index ); } if verifier_state.finalized_indices != *selected_circuit_indexes { bail!("CutCircuits finalized indices conflict with persisted selection"); } - enqueue_soldering_proof_ready(context, soldering_proof_ready).await?; + let message = GOATMessage::new( + Actor::Operator, + GOATMessageContent::SolderingProofReady(soldering_proof_ready), + ); + let operator_peer_id = PeerId::from_bytes(&verifier_state.operator_peer_id) + .map(|peer_id| peer_id.to_string()) + .context("decode operator peer id saved from InitGraph")?; + let outbox_id = + enqueue_graph_setup_outbox_message(&context.local_db, message, Some(&operator_peer_id)) + .await?; + tracing::info!( + event = "verifier_soldering_proof", + outcome = "enqueued", + outbox_id, + "enqueued SolderingProofReady for swarm publication" + ); return Ok(()); } @@ -1764,7 +1972,7 @@ async fn handle_cut_circuits_verifier( let soldering_proof_ready = save_soldering_proof_payload( instance_id, graph_id, - verifier_index, + candidate_index, &opened, &finalized, &soldering, @@ -1773,42 +1981,27 @@ async fn handle_cut_circuits_verifier( verifier_state.finalized_indices = selected_circuit_indexes.clone(); verifier_state.soldering_proof_ready = Some(soldering_proof_ready.clone()); + let operator_peer_id = PeerId::from_bytes(&verifier_state.operator_peer_id) + .map(|peer_id| peer_id.to_string()) + .context("decode operator peer id saved from InitGraph")?; update_babe_setup_state(&context.local_db, instance_id, graph_id, |state| { state.verifier = Some(verifier_state); })?; - enqueue_soldering_proof_ready(context, soldering_proof_ready).await?; - - Ok(()) -} - -async fn enqueue_soldering_proof_ready( - context: &HeavyTaskContext, - soldering_proof_ready: SolderingProofReady, -) -> Result<()> { - let outbox_id = format!( - "soldering-proof-ready:{}:{}:{}", - soldering_proof_ready.graph_id, - soldering_proof_ready.verifier_index, - hex::encode(soldering_proof_ready.payload_hash), - ); let message = GOATMessage::new( Actor::Operator, GOATMessageContent::SolderingProofReady(soldering_proof_ready), ); - let serialized = message.serialize_message().await?; - context - .local_db - .acquire() - .await? - .enqueue_p2p_outbox_message(&outbox_id, message.content.event_type(), &serialized) - .await?; + let outbox_id = + enqueue_graph_setup_outbox_message(&context.local_db, message, Some(&operator_peer_id)) + .await?; tracing::info!( event = "verifier_soldering_proof", outcome = "enqueued", outbox_id, "enqueued SolderingProofReady for swarm publication" ); + Ok(()) } @@ -1817,7 +2010,7 @@ pub(crate) async fn handle_soldering_proof_ready_operator( context: &HeavyTaskContext, instance_id: Uuid, graph_id: Uuid, - verifier_index: usize, + candidate_index: usize, payload_hash: [u8; 32], total_len: usize, ) -> Result<()> { @@ -1825,7 +2018,7 @@ pub(crate) async fn handle_soldering_proof_ready_operator( bail!("SolderingProofReady total_len must be greater than zero"); } let soldering_proof_ready = - SolderingProofReady { instance_id, graph_id, verifier_index, payload_hash, total_len }; + SolderingProofReady { instance_id, graph_id, candidate_index, payload_hash, total_len }; let operator_master_key = OperatorMasterKey::new(get_bitvm_key()?); let local_operator_pubkey = operator_master_key.master_keypair().public_key().into(); if !pending_graph_belongs_to_operator( @@ -1858,28 +2051,40 @@ pub(crate) async fn handle_soldering_proof_ready_operator( format!("missing operator BABE setup state for pending graph {graph_id}"), ) })?; - let frozen = operator_state.frozen_verifier_pubkeys.as_ref().ok_or_else(|| { + let frozen = operator_state.candidate_verifier_pubkeys.as_ref().ok_or_else(|| { retryable_dispatch_error( RetryableDispatchReason::DependencyPending, Some(30), "operator verifier membership is not frozen", ) })?; - let verifier_pubkey = frozen.get(verifier_index).ok_or_else(|| { - anyhow!("SolderingProofReady verifier index {verifier_index} out of range") + let verifier_pubkey = frozen.get(candidate_index).ok_or_else(|| { + anyhow!("SolderingProofReady candidate index {candidate_index} out of range") })?; let candidate = operator_state .candidates .iter() .find(|candidate| candidate.verifier_pubkey == *verifier_pubkey) .ok_or_else(|| anyhow!("selected verifier candidate is missing"))?; - if candidate.verifier_index != Some(verifier_index) { + if candidate.candidate_index != Some(candidate_index) { bail!("selected verifier candidate index does not match SolderingProofReady slot"); } + if operator_state.selected_verifier_pubkeys.as_ref().is_some_and(|selected| { + !selected.iter().any(|selected_pubkey| selected_pubkey == verifier_pubkey) + }) { + tracing::info!( + instance_id = %instance_id, + graph_id = %graph_id, + candidate_index, + verifier_pubkey = %verifier_pubkey, + "Ignore late SolderingProofReady from verifier outside the sealed graph selection" + ); + return Ok(()); + } let verifier_peer_id = context.from_peer_id.to_bytes(); if candidate.verifier_peer_id != verifier_peer_id { tracing::warn!( - "Ignore SolderingProofReady for {instance_id}:{graph_id}: sender {} does not own verifier slot {verifier_index}", + "Ignore SolderingProofReady for {instance_id}:{graph_id}: sender {} does not own verifier candidate {candidate_index}", context.from_peer_id ); return Ok(()); @@ -1890,14 +2095,14 @@ pub(crate) async fn handle_soldering_proof_ready_operator( &store_base_path, instance_id, graph_id, - verifier_index, + candidate_index, &payload_hash, )?; tracing::info!( from_peer_id = %context.from_peer_id, instance_id = %instance_id, graph_id = %graph_id, - verifier_index, + candidate_index, total_len, payload_hash = %soldering_payload_hash_hex(&payload_hash), payload_path = %payload_path, @@ -1909,7 +2114,7 @@ pub(crate) async fn handle_soldering_proof_ready_operator( tracing::error!( instance_id = %instance_id, graph_id = %graph_id, - verifier_index, + candidate_index, payload_path = %payload_path, error = %err, "failed to read soldering proof payload from store" @@ -1924,7 +2129,7 @@ pub(crate) async fn handle_soldering_proof_ready_operator( tracing::info!( instance_id = %instance_id, graph_id = %graph_id, - verifier_index, + candidate_index, bytes = payload.len(), total_len, payload_hash = %soldering_payload_hash_hex(&payload_hash), @@ -1941,13 +2146,13 @@ fn decode_soldering_proof_payload( soldering_proof_ready: &SolderingProofReady, payload: &[u8], ) -> Result { - let SolderingProofReady { instance_id, graph_id, verifier_index, payload_hash, total_len } = + let SolderingProofReady { instance_id, graph_id, candidate_index, payload_hash, total_len } = soldering_proof_ready; if payload.len() != *total_len { tracing::warn!( instance_id = %instance_id, graph_id = %graph_id, - verifier_index, + candidate_index, actual_len = payload.len(), total_len, payload_hash = %soldering_payload_hash_hex(payload_hash), @@ -1963,7 +2168,7 @@ fn decode_soldering_proof_payload( tracing::warn!( instance_id = %instance_id, graph_id = %graph_id, - verifier_index, + candidate_index, expected_hash = %soldering_payload_hash_hex(payload_hash), actual_hash = %soldering_payload_hash_hex(&actual_hash), "SolderingProof payload hash mismatch" @@ -1983,7 +2188,7 @@ pub(crate) async fn handle_soldering_proof_payload_operator( event = "operator_soldering_proof", stage = "payload_decode", outcome = "started", - verifier_index = soldering_proof_ready.verifier_index, + candidate_index = soldering_proof_ready.candidate_index, payload_len = payload.len(), "decoding soldering proof payload" ); @@ -1992,7 +2197,7 @@ pub(crate) async fn handle_soldering_proof_payload_operator( event = "operator_soldering_proof", stage = "payload_decode", outcome = "completed", - verifier_index = soldering_proof_ready.verifier_index, + candidate_index = soldering_proof_ready.candidate_index, elapsed_ms = decode_started_at.elapsed().as_millis(), "decoded soldering proof payload" ); @@ -2008,7 +2213,7 @@ async fn handle_compact_soldering_proof_operator( ) -> Result<()> { let instance_id = soldering_proof_ready.instance_id; let graph_id = soldering_proof_ready.graph_id; - let verifier_index = soldering_proof_ready.verifier_index; + let candidate_index = soldering_proof_ready.candidate_index; let operator_master_key = OperatorMasterKey::new(get_bitvm_key()?); let local_operator_pubkey = operator_master_key.master_keypair().public_key().into(); if !pending_graph_belongs_to_operator( @@ -2041,7 +2246,7 @@ async fn handle_compact_soldering_proof_operator( format!("missing operator BABE setup state for pending graph {graph_id}"), ) })?; - let frozen = operator_state.frozen_verifier_pubkeys.as_ref().ok_or_else(|| { + let frozen = operator_state.candidate_verifier_pubkeys.as_ref().ok_or_else(|| { retryable_dispatch_error( RetryableDispatchReason::DependencyPending, Some(30), @@ -2049,15 +2254,15 @@ async fn handle_compact_soldering_proof_operator( ) })?; let verifier_pubkey = *frozen - .get(verifier_index) - .ok_or_else(|| anyhow!("SolderingProof verifier index {verifier_index} out of range"))?; + .get(candidate_index) + .ok_or_else(|| anyhow!("SolderingProof candidate index {candidate_index} out of range"))?; let candidate = operator_state .candidates .iter() .find(|candidate| candidate.verifier_pubkey == verifier_pubkey) .ok_or_else(|| anyhow!("selected verifier candidate is missing"))?; - if candidate.verifier_index != Some(verifier_index) { + if candidate.candidate_index != Some(candidate_index) { bail!("selected verifier candidate index does not match SolderingProof slot"); } let setup_package = candidate.setup_package.clone(); @@ -2067,7 +2272,7 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "payload_expand", outcome = "started", - verifier_index, + candidate_index, "expanding compact soldering proof" ); let (opened, finalized, soldering) = expand_compact_soldering_proof_payload(payload) @@ -2076,7 +2281,7 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "payload_expand", outcome = "completed", - verifier_index, + candidate_index, opened_instances = opened.len(), finalized_instances = finalized.len(), elapsed_ms = expand_started_at.elapsed().as_millis(), @@ -2101,7 +2306,7 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "setup_verify", outcome = "started", - verifier_index, + candidate_index, opened_instances = opened.len(), finalized_instances = finalized.len(), "verifying verifier soldering proof" @@ -2124,7 +2329,7 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "setup_verify", outcome = "completed", - verifier_index, + candidate_index, elapsed_ms = verification_started_at.elapsed().as_millis(), "verified verifier soldering proof" ); @@ -2132,7 +2337,7 @@ async fn handle_compact_soldering_proof_operator( tracing::info!( event = "operator_graph_creation", outcome = "soldering_verified", - verifier_index, + candidate_index, verifier_pubkey = %verifier_pubkey, finalized_instances = finalized.len(), payload_hash = %hex::encode(soldering_proof_ready.payload_hash), @@ -2148,7 +2353,7 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "gc_data_extract", outcome = "started", - verifier_index, + candidate_index, "building BABE prover state and extracting GC data" ); let prover_state = build_babe_prover_state(&setup_package, finalized, soldering)?; @@ -2157,33 +2362,34 @@ async fn handle_compact_soldering_proof_operator( event = "operator_soldering_proof", stage = "gc_data_extract", outcome = "completed", - verifier_index, + candidate_index, elapsed_ms = gc_data_started_at.elapsed().as_millis(), "extracted GC data from soldering proof" ); - let Some(bitvm_gc_circuit_datas) = record_candidate_gc_data( + record_candidate_gc_data( operator_state, verifier_pubkey, - verifier_index, + candidate_index, &setup_package, gc_data, &prover_state, soldering_proof_ready.clone(), - )? - else { - let completed_slots = operator_state - .candidates - .iter() - .filter(|candidate| candidate.gc_data.is_some()) - .count(); - let expected_slots = operator_state.candidates.len(); + )?; + let completed_slots = + operator_state.candidates.iter().filter(|candidate| candidate.gc_data.is_some()).count(); + let required_slots = min_required_verifier(); + let proof_window_started_at = + operator_state.proof_collection_started_at.get_or_insert_with(current_time_secs); + let elapsed = current_time_secs() - *proof_window_started_at; + let collection_window = get_verifier_candidate_collection_window_secs(); + if completed_slots < required_slots || elapsed < collection_window { if let Err(error) = save_babe_setup_state(&context.local_db, instance_id, graph_id, &state) { tracing::error!( event = "operator_graph_creation", outcome = "failed", stage = "babe_state_persist", - verifier_index, + candidate_index, error = %error, "failed to persist incomplete operator BABE setup state" ); @@ -2192,19 +2398,45 @@ async fn handle_compact_soldering_proof_operator( tracing::info!( event = "operator_graph_creation", outcome = "waiting_for_soldering", - verifier_index, + candidate_index, completed_slots, - expected_slots, - "persisted verifier soldering proof; waiting for remaining graph slots" + required_slots, + retry_after_secs = (collection_window - elapsed).max(1), + "persisted verified soldering proof; waiting for the proof selection window" ); - return Ok(()); - }; + return Err(retryable_dispatch_error( + RetryableDispatchReason::DependencyPending, + Some((collection_window - elapsed).max(1)), + format!("collecting verified soldering proofs: {completed_slots}/{required_slots}"), + )); + } + + let selected_verifier_pubkeys = + if let Some(selected) = &operator_state.selected_verifier_pubkeys { + selected.clone() + } else { + let mut selected = operator_state + .candidates + .iter() + .filter(|candidate| candidate.gc_data.is_some()) + .map(|candidate| candidate.verifier_pubkey) + .collect::>(); + selected.sort_by_key(|candidate| candidate.to_bytes()); + selected.truncate(required_slots); + selected + }; + seal_selected_verifiers(operator_state, selected_verifier_pubkeys)?; + let bitvm_gc_circuit_datas = selected_gc_data(operator_state)?; + let mut obsolete_setup_outbox_ids = vec![format!("init-graph:{graph_id}")]; + obsolete_setup_outbox_ids.extend(operator_state.candidates.iter().map(|candidate| { + format!("cut-circuits:{instance_id}:{graph_id}:{}", candidate.verifier_pubkey) + })); if let Err(error) = save_babe_setup_state(&context.local_db, instance_id, graph_id, &state) { tracing::error!( event = "operator_graph_creation", outcome = "failed", stage = "babe_state_persist", - verifier_index, + candidate_index, error = %error, "failed to persist complete operator BABE setup state" ); @@ -2314,6 +2546,11 @@ async fn handle_compact_soldering_proof_operator( storage .insert_p2p_outbox_message(&outbox_id, message.content.event_type(), &serialized) .await?; + let mut cancelled_setup_messages = 0; + for setup_outbox_id in obsolete_setup_outbox_ids { + cancelled_setup_messages += + storage.cancel_p2p_outbox_message(&setup_outbox_id).await? as u64; + } drop(storage); tracing::info!( event = "operator_graph_creation", @@ -2322,6 +2559,7 @@ async fn handle_compact_soldering_proof_operator( graph_nonce, definition_hash = %definition_hash, outbox_id, + cancelled_setup_messages, "enqueued CreateGraph for swarm publication" ); @@ -2426,19 +2664,12 @@ async fn handle_create_graph_verifier( ); return Ok(()); }; - if soldering_proof_ready.verifier_index != verifier_index { - bail!( - "CreateGraph verifier index {verifier_index} conflicts with persisted slot {}", - soldering_proof_ready.verifier_index - ); - } - let store_base_path = get_soldering_proof_payload_store_path()?; let payload_path = soldering_proof_payload_store_path( &store_base_path, instance_id, graph_id, - verifier_index, + soldering_proof_ready.candidate_index, &soldering_proof_ready.payload_hash, )?; let payload = read_soldering_proof_store_payload(&payload_path) @@ -2478,16 +2709,26 @@ async fn handle_create_graph_verifier( let serialized = message.serialize_message().await?; let endorsement_outbox_id = format!("verifier-graph-params-endorsement:{graph_id}:{verifier_index}"); - context - .local_db - .acquire() - .await? + let mut storage = context.local_db.acquire().await?; + storage .enqueue_p2p_outbox_message( &endorsement_outbox_id, message.content.event_type(), &serialized, ) .await?; + let cancelled_gen_circuits = storage + .cancel_p2p_outbox_message(&format!( + "gen-circuits:{instance_id}:{graph_id}:{local_verifier_pubkey}" + )) + .await?; + let cancelled_soldering_proof = storage + .cancel_p2p_outbox_message(&format!( + "soldering-proof-ready:{graph_id}:{}:{}", + soldering_proof_ready.candidate_index, + hex::encode(soldering_proof_ready.payload_hash), + )) + .await?; tracing::info!( event = "verifier_graph_validation", @@ -2496,6 +2737,8 @@ async fn handle_create_graph_verifier( graph_id = %graph_id, verifier_index, endorsement_outbox_id, + cancelled_gen_circuits, + cancelled_soldering_proof, "validated CreateGraph and enqueued verifier graph params endorsement" ); Ok(()) @@ -4403,16 +4646,27 @@ async fn handle_kickoff_sent_verifier( let goat_confirmed_btc_height = ctx.goat_client.btc_spv_latest_height().await? as u32; if [WithdrawStatus::None, WithdrawStatus::Canceled].contains(&withdraw_status) { if kickoff_height >= goat_confirmed_btc_height { - tracing::warn!( - "Ignore KickoffSent for {instance_id}:{graph_id}: kickoff tx not confirmed by goat spv yet" + let delay_secs = avg_block_time_secs(ctx.btc_client.network()) + * (kickoff_height - goat_confirmed_btc_height) as u64; + push_local_unhandled_messages_with_reason( + ctx.local_db, + graph_id, + &message, + delay_secs as usize, + MessageDeferReason::GoatSpvPending, + "kickoff block is not available through GOAT SPV", + ) + .await?; + tracing::info!( + "Retry Challenge later for {instance_id}:{graph_id}: kickoff tx block is not posted to GOAT SPV yet" ); return Ok(()); - } else { - let (challenge_tx, _) = export_challenge_tx(&graph).unwrap(); - let challenge_txid = challenge_tx.compute_txid(); - if ctx.btc_client.get_tx(&challenge_txid).await?.is_none() { - send_challenge_tx(ctx.btc_client, &graph).await?; - } + } + + let (challenge_tx, _) = export_challenge_tx(&graph).unwrap(); + let challenge_txid = challenge_tx.compute_txid(); + if ctx.btc_client.get_tx(&challenge_txid).await?.is_none() { + send_challenge_tx(ctx.btc_client, &graph).await?; } } else { tracing::info!( @@ -5342,33 +5596,86 @@ async fn handle_assert_sent_verifier( }; validate_verifier_slot(&graph, verifier_index)?; - // check pubin first, if invalid, directly send PubinDisprove without building ChallengeAssert transaction - if let Ok(connector_e_input) = graph.watchtower_challenge_init.connector_e_input() { - let outpoint = connector_e_input.outpoint; - if let Some(commit_pubin_txid) = - outpoint_spent_txid(ctx.btc_client, &outpoint.txid, outpoint.vout as u64).await? - && let Some(commit_pubin_tx) = ctx.btc_client.get_tx(&commit_pubin_txid).await? - && let Some(commit_pubin_txin) = commit_pubin_tx.input.first() - { - let assert_txin = assert_tx - .input - .first() - .ok_or_else(|| anyhow!("operator assert transaction has no input"))?; - let wci_txid = graph.watchtower_challenge_init.tx().compute_txid(); - let watchtower_timeout_txids = graph - .watchtower_challenge_timeouts - .iter() - .map(|tx| tx.tx().compute_txid()) - .collect::>(); - let ack_txins = match collect_ack_txins( - ctx.btc_client, - &wci_txid, - &watchtower_timeout_txids, + // ChallengeAssert may only be skipped after this graph's commit-pubin is + // on-chain and proves consistent with the assert witness. OperatorAssert + // can otherwise be broadcast before connector-E is spent. + let connector_e_input = graph + .watchtower_challenge_init + .connector_e_input() + .map_err(|error| anyhow!("failed to resolve connector-E input: {error}"))?; + let connector_e_outpoint = connector_e_input.outpoint; + let Some(connector_e_spent_txid) = outpoint_spent_txid( + ctx.btc_client, + &connector_e_outpoint.txid, + connector_e_outpoint.vout as u64, + ) + .await? + else { + let delay_secs = avg_block_time_secs(ctx.btc_client.network()); + let message = make_message(ctx, content); + push_local_unhandled_messages_with_reason( + ctx.local_db, + graph_id, + &message, + delay_secs as usize, + MessageDeferReason::ProtocolInputsPending, + "operator commit-pubin is not on chain yet", + ) + .await?; + tracing::info!( + "Retry AssertSent later for {instance_id}:{graph_id}: operator commit-pubin is not on chain yet" + ); + return Ok(()); + }; + + let mut pubin_consistent = false; + if connector_e_spent_txid != graph.operator_commit_timeout.tx().compute_txid() { + if !ctx.btc_client.get_tx_status(&connector_e_spent_txid).await?.confirmed { + let delay_secs = avg_block_time_secs(ctx.btc_client.network()); + let message = make_message(ctx, content); + push_local_unhandled_messages_with_reason( + ctx.local_db, + graph_id, + &message, + delay_secs as usize, + MessageDeferReason::ProtocolInputsPending, + "operator commit-pubin transaction is not confirmed yet", ) - .await - { + .await?; + return Ok(()); + } + let Some(commit_pubin_tx) = ctx.btc_client.get_tx(&connector_e_spent_txid).await? else { + let delay_secs = avg_block_time_secs(ctx.btc_client.network()); + let message = make_message(ctx, content); + push_local_unhandled_messages_with_reason( + ctx.local_db, + graph_id, + &message, + delay_secs as usize, + MessageDeferReason::ProtocolInputsPending, + "operator commit-pubin transaction is not available yet", + ) + .await?; + return Ok(()); + }; + let assert_txin = assert_tx + .input + .first() + .ok_or_else(|| anyhow!("operator assert transaction has no input"))?; + let commit_pubin_txin = commit_pubin_tx + .input + .first() + .ok_or_else(|| anyhow!("operator commit-pubin transaction has no input"))?; + let wci_txid = graph.watchtower_challenge_init.tx().compute_txid(); + let watchtower_timeout_txids = graph + .watchtower_challenge_timeouts + .iter() + .map(|tx| tx.tx().compute_txid()) + .collect::>(); + let ack_txins = + match collect_ack_txins(ctx.btc_client, &wci_txid, &watchtower_timeout_txids).await { Ok(txins) => txins, - Err(e) => { + Err(error) => { let delay_secs = avg_block_time_secs(ctx.btc_client.network()); let message = make_message(ctx, content); push_local_unhandled_messages_with_reason( @@ -5377,74 +5684,55 @@ async fn handle_assert_sent_verifier( &message, delay_secs as usize, MessageDeferReason::ProtocolInputsPending, - &format!("operator ACK inputs are not ready: {e}"), + &format!("operator ACK inputs are not ready: {error}"), ) .await?; - tracing::info!( - "Retry AssertSent later for {instance_id}:{graph_id}: ACK inputs are not ready: {e}" - ); return Ok(()); } }; - match validate_pubin_disprove(&graph, commit_pubin_txin, assert_txin, &ack_txins) { - Ok(Some((witness_data, _))) => { - // PubinDisprove is not pre-signed, so fund it directly rather - // than relying on CPFP. - let pubin_disprove_tx_total_input_amount = graph - .operator_assert - .connector_d_input() - .map_err(|e| anyhow!("failed to get connector-d input: {e}"))? - .amount; - let pubin_disprove_txin = build_pubin_disprove_txin(&graph, witness_data)?; - let pubin_disprove_tx = bitcoin::Transaction { - version: bitcoin::transaction::Version(2), - lock_time: bitcoin::absolute::LockTime::ZERO, - input: vec![pubin_disprove_txin], - output: vec![ - goat::scripts::p2a_output(), - // Keep the transaction above Bitcoin Core's minimum - // non-witness relay size. - bitcoin::TxOut { - value: Amount::ZERO, - script_pubkey: goat::scripts::generate_opreturn_script( - PUBIN_DISPROVE_OP_RETURN_DATA.to_vec(), - ), - }, - ], - }; - let verifier_keypair = - VerifierMasterKey::new(get_bitvm_key()?).master_keypair(); - build_sign_and_broadcast_tx( - ctx.btc_client, - verifier_keypair, - pubin_disprove_tx.input, - pubin_disprove_tx_total_input_amount, - pubin_disprove_tx.output, - ) - .await?; - return Ok(()); - } - Ok(None) => tracing::debug!( - "PubinDisprove invalid for {instance_id}:{graph_id}: operator pubin consistent, proceeding to ChallengeAssert" - ), - Err(e) => { - let delay_secs = avg_block_time_secs(ctx.btc_client.network()); - let message = make_message(ctx, content); - push_local_unhandled_messages_with_reason( - ctx.local_db, - graph_id, - &message, - delay_secs as usize, - MessageDeferReason::ValidationRetry, - &format!("PubinDisprove validation could not complete: {e}"), - ) - .await?; - tracing::warn!( - "Retry AssertSent later for {instance_id}:{graph_id}: PubinDisprove check failed: {e}" - ); - return Ok(()); - } + match validate_pubin_disprove(&graph, commit_pubin_txin, assert_txin, &ack_txins) { + Ok(Some((witness_data, _))) => { + // PubinDisprove is not pre-signed, so fund it directly rather + // than relying on CPFP. + let pubin_disprove_tx_total_input_amount = graph + .operator_assert + .connector_d_input() + .map_err(|e| anyhow!("failed to get connector-d input: {e}"))? + .amount; + let pubin_disprove_txin = build_pubin_disprove_txin(&graph, witness_data)?; + let pubin_disprove_tx = bitcoin::Transaction { + version: bitcoin::transaction::Version(2), + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![pubin_disprove_txin], + output: vec![ + goat::scripts::p2a_output(), + // Keep the transaction above Bitcoin Core's minimum + // non-witness relay size. + bitcoin::TxOut { + value: Amount::ZERO, + script_pubkey: goat::scripts::generate_opreturn_script( + PUBIN_DISPROVE_OP_RETURN_DATA.to_vec(), + ), + }, + ], + }; + let verifier_keypair = VerifierMasterKey::new(get_bitvm_key()?).master_keypair(); + build_sign_and_broadcast_tx( + ctx.btc_client, + verifier_keypair, + pubin_disprove_tx.input, + pubin_disprove_tx_total_input_amount, + pubin_disprove_tx.output, + ) + .await?; + return Ok(()); + } + Ok(None) => { + pubin_consistent = true; } + Err(error) => tracing::warn!( + "PubinDisprove validation failed for {instance_id}:{graph_id}: {error}; proceeding to ChallengeAssert" + ), } } @@ -5457,7 +5745,7 @@ async fn handle_assert_sent_verifier( .map_err(|e| anyhow!("failed to extract operator assert witness: {e}"))?; let vk = crate::vk::get_vk().await.context("load Groth16 verifying key for operator assert")?; let static_input = derive_operator_static_input()?; - if assert_witness.verify_groth16_proof(&vk, &[static_input]) { + if pubin_consistent && assert_witness.verify_groth16_proof(&vk, &[static_input]) { tracing::info!( "Skip ChallengeAssert for {instance_id}:{graph_id}: operator assert proof is valid" ); @@ -5525,7 +5813,7 @@ pub async fn broadcast_verifier_challenge_assert_tx( ); return Ok(None); }; - let Some(soldering_proof_ready) = saved_verifier_state.soldering_proof_ready.as_ref() else { + if saved_verifier_state.soldering_proof_ready.is_none() { if strict { bail!("missing soldering proof reference for {instance_id}:{graph_id}"); } @@ -5533,15 +5821,6 @@ pub async fn broadcast_verifier_challenge_assert_tx( "Ignore AssertSent for {instance_id}:{graph_id}: missing soldering proof reference" ); return Ok(None); - }; - if soldering_proof_ready.verifier_index != verifier_index { - if strict { - bail!("local setup slot does not match graph owner slot for {instance_id}:{graph_id}"); - } - tracing::warn!( - "Ignore AssertSent for {instance_id}:{graph_id}: local setup slot does not match graph owner slot" - ); - return Ok(None); } let challenge_witness = build_real_challenge_assert_witness( &saved_verifier_state.private_state, @@ -5672,11 +5951,7 @@ async fn handle_challenge_assert_sent_operator( let operator_state = setup_state .operator .ok_or_else(|| anyhow!("missing operator BABE setup state for graph {graph_id}"))?; - let candidate = operator_state - .candidates - .iter() - .find(|candidate| candidate.verifier_index == Some(verifier_index)) - .ok_or_else(|| anyhow!("missing BABE prover state for verifier slot {verifier_index}"))?; + let candidate = selected_candidate_for_graph_index(&operator_state, verifier_index)?; let soldering_proof_ready = candidate.soldering_proof_ready.as_ref().ok_or_else(|| { anyhow!("missing soldering proof reference for verifier slot {verifier_index}") })?; @@ -5685,7 +5960,7 @@ async fn handle_challenge_assert_sent_operator( &store_base_path, instance_id, graph_id, - verifier_index, + soldering_proof_ready.candidate_index, &soldering_proof_ready.payload_hash, )?; let payload = read_soldering_proof_store_payload(&payload_path) @@ -6552,7 +6827,7 @@ mod tests { SolderingProofReady { instance_id: Uuid::nil(), graph_id: Uuid::nil(), - verifier_index: 0, + candidate_index: 0, payload_hash: [hash_byte; 32], total_len: 1, } @@ -6560,16 +6835,19 @@ mod tests { fn operator_state(package: CACSetupPackage) -> OperatorBabeSetupState { OperatorBabeSetupState { - frozen_verifier_pubkeys: Some(vec![verifier_pubkey()]), + candidate_verifier_pubkeys: Some(vec![verifier_pubkey()]), candidates: vec![OperatorVerifierCandidate { verifier_peer_id: vec![1], verifier_pubkey: verifier_pubkey(), setup_package: package, - verifier_index: Some(0), + candidate_index: Some(0), selected_circuit_indexes: (0..BABE_M_CC).collect(), gc_data: None, soldering_proof_ready: None, }], + candidate_collection_started_at: None, + proof_collection_started_at: None, + selected_verifier_pubkeys: None, asserted_operator_proof: None, } } @@ -6578,16 +6856,19 @@ mod tests { fn freeze_operator_candidate_selects_protocol_finalized_count() { let package = build_setup_package(BABE_M_CC + 1).unwrap(); let mut state = OperatorBabeSetupState { - frozen_verifier_pubkeys: None, + candidate_verifier_pubkeys: None, candidates: vec![OperatorVerifierCandidate { verifier_peer_id: vec![1], verifier_pubkey: verifier_pubkey(), setup_package: package, - verifier_index: None, + candidate_index: None, selected_circuit_indexes: vec![], gc_data: None, soldering_proof_ready: None, }], + candidate_collection_started_at: None, + proof_collection_started_at: None, + selected_verifier_pubkeys: None, asserted_operator_proof: None, }; @@ -6602,7 +6883,7 @@ mod tests { let mut state = operator_state(package.clone()); let soldering_proof_ready = soldering_proof_ready(1); - let graph_data = record_candidate_gc_data( + record_candidate_gc_data( &mut state, verifier_pubkey(), 0, @@ -6611,17 +6892,18 @@ mod tests { &prover_state, soldering_proof_ready.clone(), ) - .unwrap() .unwrap(); - assert_eq!(graph_data.len(), 1); - assert_eq!(graph_data[0].final_msg_hashlocks.len(), BABE_M_CC); + assert_eq!( + state.candidates[0].gc_data.as_ref().unwrap().final_msg_hashlocks.len(), + BABE_M_CC + ); assert_eq!( state.candidates[0].soldering_proof_ready.as_ref(), Some(&soldering_proof_ready) ); - let duplicate = record_candidate_gc_data( + record_candidate_gc_data( &mut state, verifier_pubkey(), 0, @@ -6630,9 +6912,7 @@ mod tests { &prover_state, soldering_proof_ready.clone(), ) - .unwrap() .unwrap(); - assert_eq!(duplicate.len(), 1); let mut conflict = prover_state; conflict.h_msgs[0][0] ^= 1; diff --git a/node/src/metrics_service.rs b/node/src/metrics_service.rs index f23fc75fd..d4ca387ce 100644 --- a/node/src/metrics_service.rs +++ b/node/src/metrics_service.rs @@ -15,7 +15,7 @@ use prometheus_client::metrics::counter::Counter; use prometheus_client::metrics::family::Family; use prometheus_client::metrics::gauge::Gauge; use prometheus_client::metrics::histogram::{Histogram, exponential_buckets}; -use store::{GraphStatus, InstanceBridgeInStatus, InstanceBridgeOutStatus, MessageState}; +use store::{GraphStatus, InstanceBridgeInStatus, MessageState, SwapEscrowStatus}; use tokio::time::Instant; const METRICS_CONTENT_TYPE: &str = "application/openmetrics-text;charset=utf-8;version=1.0.0"; @@ -689,14 +689,14 @@ impl MetricsState { for count in counts { match count.category.as_str() { - "instance_bridge_in" => { + "instance" => { let status = known_status::(&count.state); self.instances .get_or_create(&InstanceLabels { flow: "bridge_in".to_string(), status }) .inc_by(count.count); } - "instance_bridge_out" => { - let status = known_status::(&count.state); + "swap_escrow" => { + let status = known_status::(&count.state); self.instances .get_or_create(&InstanceLabels { flow: "bridge_out".to_string(), status }) .inc_by(count.count); diff --git a/node/src/rpc_service/bitvm.rs b/node/src/rpc_service/bitvm.rs index c352ae99f..061f4bcb5 100644 --- a/node/src/rpc_service/bitvm.rs +++ b/node/src/rpc_service/bitvm.rs @@ -15,8 +15,8 @@ use std::default::Default; use std::str::FromStr; use store::localdb::GraphQuery; use store::{ - ByteArray32, Graph, GraphStatus, Instance, InstanceBridgeInStatus, InstanceBridgeOutStatus, - ProofState, SerializableTxid, UInt64Array3, + ByteArray32, Graph, GraphStatus, Instance, InstanceBridgeInStatus, ProofState, + SerializableTxid, UInt64Array3, }; use strum::{Display, EnumString}; use tracing::warn; @@ -48,48 +48,17 @@ const BRIDGE_IN_FAIL_AS_VERIFICATION_FAILED: &str = "Unfortunately, the verifica const BRIDGE_IN_FAIL_AS_L2_MINTED_FAILED: &str = "Unfortunately, PegBTC minted failed."; const BRIDGE_IN_FAIL_AS_TIMEOUT: &str = "The operation timed out. Please try again."; -const _BRIDGE_OUT_FAIL_AS_CLAIM_TIMEOUT: &str = - "Claim timed out. Please initiate a new transaction."; const _BRIDGE_IN_FAIL_AS_L1_LOCK_TIMEOUT: &str = "The operator timed out and failed to lock BTC. Please cancel the transaction."; pub const BRIDGE_IN_AMOUNTS: [f32; 2] = [0.1, 0.01]; const GOAT_BLOCK_INTERVAL_SECS: i64 = 3; -const INSTANCE_BRIDGE_OUT_INIT_STATUS_DURATION_SECS: i64 = 3600; // todo update const INSTANCE_USER_BROADCAST_PREPARE_STATUS_DURATION_SECS: i64 = 3600 * 3; const INSTANCE_RELAYER_L1_BROADCAST_STATUS_DURATION_SECS: i64 = 3600 * 3; const GRAPH_OPERATOR_KICKOFFING_STATUS_DURATION_SECS: i64 = 1800; const GRAPH_OPERATOR_KICKOFF_STATUS_DURATION_SECS: i64 = 3600 * 3; const GRAPH_OPERATOR_CHALLENGE_STATUS_DURATION_SECS: i64 = 3600 * 9; -#[derive(Debug, Deserialize, Serialize)] -pub struct BridgeInPrepareRequest { - pub instance_id: String, // UUID - pub contract_address: String, // gateway address - pub from_addr: String, // BTC /charge - pub to_addr: String, // BTC /charge - pub bridge_request_tx_hash: String, // goat tx hash -} -#[derive(Debug, Deserialize, Serialize)] -pub struct BridgeInPrepareResponse {} - -#[derive(Debug, Deserialize, Serialize)] -pub struct BridgeOutInitTagRequest { - pub contract_address: String, // gateway address - pub from_addr: String, // goat addr - pub to_addr: String, // btc addr - pub escrow_hash: String, // goat tx hash -} -#[derive(Debug, Deserialize, Serialize)] -pub struct BridgeOutInitTagResponse {} - -#[derive(Debug, Deserialize, Serialize)] -pub struct EscrowDataResponse { - pub instance_id: String, - pub escrow: Option, - pub error: Option, -} - #[derive(Debug, Deserialize, Serialize)] pub struct InstanceSettingResponse { pub bridge_in_amount: Vec, @@ -140,7 +109,6 @@ pub struct GraphTxnGetParams { /// get tx detail #[derive(Debug, Deserialize)] pub struct InstanceListRequest { - pub is_bridge_in: bool, pub from_addr: Option, pub offset: Option, pub limit: Option, @@ -164,7 +132,6 @@ pub struct StatusExtra { #[derive(Deserialize, Serialize, Default)] pub struct InstanceDisplay { pub instance_id: Uuid, - pub is_bridge_in: bool, pub network: String, pub from_addr: String, pub to_addr: String, // goat deposit addr @@ -172,21 +139,19 @@ pub struct InstanceDisplay { pub amount: U256, pub fees: UInt64Array3, pub input_utxos: String, // init should been [] - pub status: String, // InstanceBridgeInStatus | InstanceBridgeOutStatus - pub goat_tx_hash: String, // bridgeIn:pegin Request tx || bridgeOut goat tx + pub status: String, // InstanceBridgeInStatus + pub goat_tx_hash: String, // pegin Request tx pub goat_tx_height: i64, pub user_xonly_pubkey: ByteArray32, pub user_change_addr: String, pub user_refund_addr: String, - pub btc_txid: Option, // bridgeIn: Pegin Prepare Request tx || bridgeOut Btc tx + pub btc_txid: Option, // Pegin Prepare Request tx pub btc_height: i64, pub pegin_confirm_txid: Option, // btc txid pub pegin_cancel_txid: Option, // btc txid pub committees_answers: IndexMap>, pub pegin_data_tx_hash: String, pub parameters: Option, - pub escrow_hash: Option, - pub bridge_out_lock_time: i64, pub post_pegin_txhash: Option, pub status_updated_at: i64, pub created_at: i64, @@ -194,18 +159,12 @@ pub struct InstanceDisplay { } impl From for InstanceDisplay { fn from(instance: Instance) -> Self { - let amount = if instance.is_bridge_in { - U256::from(instance.amount) - } else { - U256::from_str(&instance.bridge_out_amount).unwrap_or_default() - }; Self { instance_id: instance.instance_id, - is_bridge_in: instance.is_bridge_in, network: instance.network, from_addr: instance.from_addr, to_addr: instance.to_addr, - amount, + amount: U256::from(instance.amount), fees: instance.fees, input_utxos: instance.input_utxos, status: instance.status, @@ -221,8 +180,6 @@ impl From for InstanceDisplay { committees_answers: instance.committees_answers, pegin_data_tx_hash: instance.pegin_data_tx_hash, parameters: instance.parameters, - escrow_hash: instance.escrow_hash, - bridge_out_lock_time: instance.bridge_out_lock_time, post_pegin_txhash: instance.post_pegin_txhash, status_updated_at: instance.status_updated_at, created_at: instance.created_at, @@ -245,7 +202,6 @@ pub struct InstanceExtended { impl InstanceExtended { pub async fn convert_from_instance( btc_client: &BTCClient, - btc_current_height: u32, response_window_blocks: i64, instance: Instance, ) -> anyhow::Result { @@ -255,17 +211,9 @@ impl InstanceExtended { instance.input_utxos, e )) })?; - let (confirmations, target_confirmations) = get_instance_block_confirm_progress( - btc_client, - btc_current_height, - instance.is_bridge_in, - instance.btc_txid.clone(), - ) - .await?; let status_extra = get_instance_status_extra( btc_client, instance.instance_id, - instance.is_bridge_in, instance.status.clone(), instance.btc_txid.clone().map(|v| v.0.to_string()), &utxos, @@ -274,20 +222,18 @@ impl InstanceExtended { .await?; let (current_status_waiting_time_in_secs, waiting_time_in_secs) = get_instance_waiting_times( - instance.is_bridge_in, &instance.status, instance.created_at, instance.status_updated_at, response_window_blocks, - instance.bridge_out_lock_time, ); // instance.status = instance.convert_to_display_status(); Ok(Self { waiting_time_in_secs, current_status_waiting_time_in_secs, - confirmations, - target_confirmations, + confirmations: 0, + target_confirmations: 0, status_extra, utxo: utxos, instance: instance.into(), @@ -298,14 +244,13 @@ impl InstanceExtended { async fn get_instance_status_extra( btc_client: &BTCClient, instance_id: Uuid, - is_bridge_in: bool, status: String, target_txid: Option, utxos: &[Utxo], pegin_cancel_txid: Option, ) -> anyhow::Result { let mut status_extra = StatusExtra::default(); - if is_bridge_in && let Ok(bridge_in_status) = InstanceBridgeInStatus::from_str(&status) { + if let Ok(bridge_in_status) = InstanceBridgeInStatus::from_str(&status) { match bridge_in_status { InstanceBridgeInStatus::UserInited => { if !check_bridge_in_uxto_available_or_self_spent(btc_client, target_txid, utxos) @@ -369,40 +314,12 @@ async fn get_instance_status_extra( } _ => {} } - } else if !is_bridge_in - && let Ok(bridge_out_status) = InstanceBridgeOutStatus::from_str(&status) - { - if bridge_out_status == InstanceBridgeOutStatus::Timeout { - status_extra.is_failed = true; - status_extra.error = Some(BRIDGE_IN_FAIL_AS_TIMEOUT.to_string()); - status_extra.user_action = StatusUserAction::Refund; - } } else { - warn!("instance:with {instance_id}, is_bridge_in:{is_bridge_in} has wrong status:{status}"); + warn!("instance:with {instance_id} has wrong status:{status}"); } Ok(status_extra) } -async fn get_instance_block_confirm_progress( - btc_client: &BTCClient, - current_height: u32, - is_bridge_in: bool, - txid: Option, -) -> anyhow::Result<(u32, u32)> { - if !is_bridge_in { - if let Some(txid) = txid - && let Ok(tx_status) = btc_client.get_tx_status(&txid.into()).await - && let Some(height) = tx_status.block_height - { - Ok((current_height + 1_u32 - height, 6_u32)) - } else { - Ok((0, 6)) - } - } else { - Ok((0, 0)) - } -} - fn get_bridge_in_status_time_window_secs(status: &str, response_window_blocks: i64) -> (i64, i64) { let response_window_blocks_with_margin = response_window_blocks * 105 / 100; let total_time = response_window_blocks_with_margin * GOAT_BLOCK_INTERVAL_SECS @@ -435,41 +352,18 @@ fn get_bridge_in_status_time_window_secs(status: &str, response_window_blocks: i } } -// dead time -fn get_bridge_out_status_time_window_secs(status: &str, bridge_out_lock_time: i64) -> (i64, i64) { - let deadline_time = if bridge_out_lock_time > 0 { - bridge_out_lock_time - } else { - // for history data not set bridge_out_lock_time - INSTANCE_BRIDGE_OUT_INIT_STATUS_DURATION_SECS - }; - - match InstanceBridgeOutStatus::from_str(status) { - Ok(InstanceBridgeOutStatus::Initialize) => (deadline_time, deadline_time), - Ok(_) | Err(_) => (0, 0), - } -} - fn get_instance_waiting_times( - is_bridge_in: bool, status: &str, created_at: i64, last_updated: i64, response_window_blocks: i64, - bridge_out_lock_time: i64, ) -> (i64, i64) { let current_time = current_time_secs(); let current_state_past = current_time - last_updated; let total_past = current_time - created_at; - if is_bridge_in { - let (current_status_window, total_window) = - get_bridge_in_status_time_window_secs(status, response_window_blocks); - ((current_status_window - current_state_past).max(0), (total_window - total_past).max(0)) - } else { - let (current_status_deadline, status_deadline) = - get_bridge_out_status_time_window_secs(status, bridge_out_lock_time); - ((current_status_deadline - current_time).max(0), (status_deadline - current_time).max(0)) - } + let (current_status_window, total_window) = + get_bridge_in_status_time_window_secs(status, response_window_blocks); + ((current_status_window - current_state_past).max(0), (total_window - total_past).max(0)) } #[derive(Deserialize, Serialize, Default)] @@ -797,9 +691,6 @@ impl DisplayStatusConvert for Graph { impl DisplayStatusConvert for Instance { fn convert_to_display_status(&self) -> String { - if !self.is_bridge_in { - return self.status.clone(); - } match InstanceBridgeInStatus::from_str(&self.status) { Ok(InstanceBridgeInStatus::UserInited) => InstanceBridgeInStatus::Initiated.to_string(), Ok(InstanceBridgeInStatus::CommitteesAnswered) => { @@ -892,7 +783,6 @@ mod tests { get_instance_status_extra( btc_client, Uuid::new_v4(), - true, InstanceBridgeInStatus::Timeout.to_string(), None, &[], diff --git a/node/src/rpc_service/handler/bitvm_handler.rs b/node/src/rpc_service/handler/bitvm_handler.rs index 05719b3d2..ae9179197 100644 --- a/node/src/rpc_service/handler/bitvm_handler.rs +++ b/node/src/rpc_service/handler/bitvm_handler.rs @@ -1,9 +1,8 @@ use crate::env::{ - ENV_GOAT_GATEWAY_CONTRACT_ADDRESS, ENV_GOAT_SWAP_CONTRACT_ADDRESS, GraphBtcTxName, - get_goat_address_from_env, get_goat_gateway_contract_from_env, get_network, - get_node_goat_address, get_node_pubkey, + GraphBtcTxName, get_goat_gateway_contract_from_env, get_node_goat_address, get_node_pubkey, }; use crate::handle::broadcast_verifier_challenge_assert_tx; +use crate::rpc_service::AppState; use crate::rpc_service::auth::verify_request_auth; use crate::rpc_service::bitvm::*; use crate::rpc_service::node::ALIVE_TIME_JUDGE_THRESHOLD; @@ -11,13 +10,11 @@ use crate::rpc_service::response::{ ApiErrorExt, ApiResult, ErrorResponse, error_response, ok_response, }; use crate::rpc_service::validation::InputValidator; -use crate::rpc_service::{AppState, current_time_secs}; use crate::utils::{ - bridge_out_instance_id_from_escrow_hash, find_instances_by_escrow_hash, gen_instance_parameters_local, get_bridge_out_global_stats, load_validated_graph_definition, obsolete_graph, send_challenge_tx, }; -use alloy::primitives::{Address, U256}; +use alloy::primitives::U256; use axum::Json; use axum::extract::{Path, Query, State}; use bitcoin::consensus::encode::serialize_hex; @@ -29,23 +26,9 @@ use std::collections::HashSet; use std::default::Default; use std::str::FromStr; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; -use store::localdb::{GraphQuery, InstanceQuery, InstanceUpdate, StorageProcessor}; -use store::{ - GoatTxType, Graph, GraphStatus, Instance, InstanceBridgeInStatus, InstanceBridgeOutStatus, -}; -use tokio::time::{Duration, sleep}; +use store::localdb::{GraphQuery, InstanceQuery, StorageProcessor}; +use store::{Graph, GraphStatus, InstanceBridgeInStatus}; use tracing::{info, warn}; -use uuid::Uuid; - -fn bridge_out_retry_jitter_ms(attempt: u32) -> u64 { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.subsec_millis() as u64) - .unwrap_or(0); - let base = 5u64.saturating_mul(1u64 << attempt.min(4)); - base + (now % 25) -} fn graph_tx_index_error( tx_name: GraphBtcTxName, @@ -94,390 +77,14 @@ pub async fn instance_settings( ok_response(InstanceSettingResponse { bridge_in_amount: BRIDGE_IN_AMOUNTS.to_vec() }) } -/// Prepare bridge-in request -/// -/// Validates user-provided data against the allowed bridge-in options returned by -/// [`instance_settings`](routes::v1::INSTANCES_SETTINGS). Clients should first call -/// `instance_settings` to know the supported `bridge_in_amount` list and then submit a -/// bridge-in request using one of those amounts. -/// -/// This function creates or updates a bridge-in instance record with status `UserIniting`. -/// The network configuration is read from the environment variable `ENV_BTC_NETWORK`, not -/// from the request body. -/// -/// # Request Body -/// -/// - `instance_id`: UUID of the bridge-in request (must be a valid UUID format) -/// - `contract_address`: GOAT chain contract address (gateway contract address). Must match -/// the gateway contract address configured in environment variables. -/// - `network`: Target Bitcoin network (e.g. `testnet3`, `mainnet`). Note: This field is -/// present in the request but the actual network is determined from environment configuration. -/// - `from_addr`: Funding Bitcoin address selected by the user (must be a valid BTC address) -/// - `to_addr`: Destination address on GOAT chain that receives bridged assets (must be a valid GOAT address) -/// - `bridge_request_tx_hash`: GOAT chain transaction hash referencing the bridge intent -/// -/// # Validation -/// -/// The function validates: -/// - `contract_address` matches the configured gateway contract address -/// - `from_addr` is a valid Bitcoin address -/// - `to_addr` is a valid GOAT chain address -/// - `instance_id` is a valid UUID format -/// -/// # Returns -/// -/// - `200 OK`: Request is valid and instance record created/updated successfully -/// - `500 Internal Server Error`: Validation failed, contract address mismatch, or database error -/// - Response body is an empty object `{}` -/// -/// # Example -/// -/// ```http -/// PUT /v1/instances/bridge-in-request-tag -/// { -/// "instance_id": "123e4567-e89b-12d3-a456-426614174000", -/// "contract_address": "0xabcdef1234567890abcdef1234567890abcdef12", -/// "from_addr": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", -/// "to_addr": "0x1234567890abcdef1234567890abcdef12345678", -/// "bridge_request_tx_hash": "0xf6d6523a4344806aca5c66f23554bc574cb93634572f5e115cc630b3d8db3c6e" -/// } -/// ``` -/// -/// Response example: -/// ```json -/// {} -/// ``` -#[axum::debug_handler] -// TODO(auth): Add caller authorization or rate limiting before exposing this write endpoint publicly. -pub async fn bridge_in_request_tag( - State(app_state): State>, - Json(payload): Json, -) -> ApiResult { - InputValidator::validate_btc_address(&payload.from_addr, None, "from_addr")?; - let to_addr = InputValidator::validate_goat_address(&payload.to_addr, "to_addr")?; - let instance_id = InputValidator::validate_uuid(&payload.instance_id, "instance_id")?; - let contract_address = - InputValidator::validate_goat_address(&payload.contract_address, "contract_address")?; - let gateway_contract: Address = get_goat_address_from_env(ENV_GOAT_GATEWAY_CONTRACT_ADDRESS) - .ok_or(anyhow::anyhow!("need to set swap contract address")) - .api_error("PUT_BRIDGE_IN_REQUEST_TAG_ERROR")?; - if contract_address != gateway_contract.to_string() { - return error_response( - format!( - "Invalid contract address: input: {contract_address}, expect:{gateway_contract}" - ), - format!( - "Invalid contract address: input: {contract_address}, expect:{gateway_contract}" - ), - ); - } - let mut storage_process = - app_state.local_db.acquire().await.api_error("PUT_BRIDGE_IN_REQUEST_TAG_ERROR")?; - let current_time = current_time_secs(); - if let Some(instance) = storage_process - .find_instance(&instance_id) - .await - .api_error("PUT_BRIDGE_IN_REQUEST_TAG_ERROR")? - { - let network = get_network().to_string(); - let has_conflict = !instance.is_bridge_in - || (!instance.network.is_empty() && instance.network != network) - || (!instance.from_addr.is_empty() && instance.from_addr != payload.from_addr) - || (!instance.to_addr.is_empty() && instance.to_addr != to_addr); - if has_conflict { - return error_response( - "BRIDGE_IN_INSTANCE_ID_CONFLICT".to_string(), - format!("instance_id {instance_id} already exists with different request data"), - ); - } - return ok_response(BridgeInPrepareResponse {}); - } - - let instance = Instance { - instance_id, - is_bridge_in: true, - network: get_network().to_string(), - from_addr: payload.from_addr, - to_addr, - input_utxos: "[]".to_string(), - status: InstanceBridgeInStatus::UserIniting.to_string(), - bridge_out_amount: "0".to_string(), - status_updated_at: current_time, - created_at: current_time, - updated_at: current_time, - ..Default::default() - }; - - storage_process - .upsert_instance(&instance) - .await - .api_error("PUT_BRIDGE_IN_REQUEST_TAG_ERROR")?; - ok_response(BridgeInPrepareResponse {}) -} - -/// Initialize bridge-out request -/// -/// Validates user-provided bridge-out data and creates a bridge-out instance record. This is the first step -/// in the bridge-out workflow, used to prepare bridging assets from L2 (GOAT) to L1 (Bitcoin). Clients should -/// provide an escrow hash (`escrow_hash`) to associate with an escrow contract already created on the GOAT chain. -/// -/// If an instance with the same `escrow_hash` already exists, the function updates the existing instance -/// if it is in `Initialize` status. Otherwise, a new instance is created with an auto-generated UUID. -/// The network configuration is read from the environment variable `ENV_BTC_NETWORK`. -/// -/// # Request Body -/// -/// - `contract_address`: GOAT chain contract address (swap contract address). Must match the swap contract -/// address configured in environment variables. -/// - `from_addr`: User's source address on the GOAT chain (must be a valid GOAT address) -/// - `to_addr`: Bitcoin destination address that receives the bridged assets (must be a valid BTC address) -/// - `escrow_hash`: Hash of the escrow contract on the GOAT chain (32-byte hex string), referencing the escrow -/// transaction for the bridge intent. Used to identify existing instances. -/// -/// # Validation -/// -/// The function validates: -/// - `contract_address` matches the configured swap contract address -/// - `from_addr` is a valid GOAT chain address -/// - `to_addr` is a valid Bitcoin address -/// - `escrow_hash` is a valid 32-byte hex string -/// -/// # Returns -/// -/// - `200 OK`: Request is valid and instance record created/updated successfully -/// - `500 Internal Server Error`: Validation failed, contract address mismatch, or database error -/// - Response body is an empty object `{}` -/// -/// # Use Case -/// -/// Frontend applications use this endpoint to initiate the bridge-out flow, recording the user's intent to bridge -/// from L2 to L1 into the system. The endpoint handles both new instance creation and updates to existing instances -/// based on the escrow hash. -/// -/// # Example -/// -/// ```http -/// PUT /v1/instances/bridge-out-init-tag -/// { -/// "contract_address": "0xabcdef1234567890abcdef1234567890abcdef12", -/// "from_addr": "0x1234567890abcdef1234567890abcdef12345678", -/// "to_addr": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", -/// "escrow_hash": "0xf6d6523a4344806aca5c66f23554bc574cb93634572f5e115cc630b3d8db3c6e" -/// } -/// ``` -/// -/// Response example: -/// ```json -/// {} -/// ``` -#[axum::debug_handler] -// TODO(auth): Add caller authorization or rate limiting before exposing this write endpoint publicly. -pub async fn bridge_out_init_tag( - State(app_state): State>, - Json(payload): Json, -) -> ApiResult { - const MAX_BRIDGE_OUT_INIT_RETRIES: u32 = 3; - - let from_addr = InputValidator::validate_goat_address(&payload.from_addr, "from_addr")?; - InputValidator::validate_btc_address(&payload.to_addr, None, "network and to_addr")?; - let contract_address = - InputValidator::validate_goat_address(&payload.contract_address, "contract_address")?; - let swap_contract: Address = get_goat_address_from_env(ENV_GOAT_SWAP_CONTRACT_ADDRESS) - .ok_or(anyhow::anyhow!("need to set swap contract address")) - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")?; - if contract_address != swap_contract.to_string() { - return error_response( - format!("Invalid contract address: input: {contract_address}, expect:{swap_contract}"), - format!("Invalid contract address: input: {contract_address}, expect:{swap_contract}"), - ); - } - let escrow_hash = InputValidator::validate_hex(&payload.escrow_hash, true, 32, "escrow_hash")?; - let mut storage_process = - app_state.local_db.acquire().await.api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")?; - let current_time = current_time_secs(); - - for attempt in 0..=MAX_BRIDGE_OUT_INIT_RETRIES { - if let Some(instance) = find_instances_by_escrow_hash(&mut storage_process, &escrow_hash) - .await - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")? - { - let updated = storage_process - .update_instance( - &InstanceUpdate::new_with_instance_id(instance.instance_id) - .with_to_addr(payload.to_addr.clone()) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false), - ) - .await - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")?; - if !updated { - warn!( - "bridge_out_init_tag ignored for resolved instance {} with status {}", - instance.instance_id, instance.status - ); - } - return ok_response(BridgeOutInitTagResponse {}); - } - - let candidate_instance_id = bridge_out_instance_id_from_escrow_hash(&escrow_hash); - let mut instance = Instance { - instance_id: candidate_instance_id, - from_addr: from_addr.clone(), - network: get_network().to_string(), - input_utxos: "[]".to_string(), - escrow_hash: Some(escrow_hash.clone()), - status: InstanceBridgeOutStatus::Initialize.to_string(), - bridge_out_amount: "0".to_string(), - status_updated_at: current_time, - created_at: current_time, - ..Default::default() - }; - instance.to_addr = payload.to_addr.clone(); - - match storage_process.insert_instance_if_absent(&instance).await { - Ok(true) => return ok_response(BridgeOutInitTagResponse {}), - Ok(false) => { - let Some(existing) = storage_process - .find_instance(&candidate_instance_id) - .await - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")? - else { - if attempt < MAX_BRIDGE_OUT_INIT_RETRIES { - sleep(Duration::from_millis(bridge_out_retry_jitter_ms(attempt))).await; - continue; - } - return error_response( - "BRIDGE_OUT_INSTANCE_ID_CONFLICT".to_string(), - "bridge-out instance disappeared while being created".to_string(), - ); - }; - let escrow_hash_matches = existing - .escrow_hash - .as_ref() - .map(|hash| hash.eq_ignore_ascii_case(&escrow_hash)) - .unwrap_or(false); - if existing.is_bridge_in || !escrow_hash_matches { - return error_response( - "BRIDGE_OUT_INSTANCE_ID_CONFLICT".to_string(), - "failed to allocate bridge-out instance_id for escrow_hash".to_string(), - ); - } - - let updated = storage_process - .update_instance( - &InstanceUpdate::new_with_instance_id(existing.instance_id) - .with_to_addr(payload.to_addr.clone()) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false), - ) - .await - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")?; - if !updated { - warn!( - "bridge_out_init_tag ignored for concurrently resolved instance {} with status {}", - existing.instance_id, existing.status - ); - } - return ok_response(BridgeOutInitTagResponse {}); - } - Err(err) => { - if attempt < MAX_BRIDGE_OUT_INIT_RETRIES { - warn!( - "bridge_out_init_tag insert failed at attempt {}, retrying: {}", - attempt, err - ); - sleep(Duration::from_millis(bridge_out_retry_jitter_ms(attempt))).await; - continue; - } - return Err(err).api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR"); - } - } - } - - error_response( - "BRIDGE_OUT_INSTANCE_ID_CONFLICT".to_string(), - "failed to initialize bridge-out instance after retries".to_string(), - ) -} - -/// Get instance escrow data -/// -/// Returns escrow hash information for a specified bridge instance. Escrow data is used in the bridge-out workflow, -/// containing information about the escrow contract created on the GOAT chain. This endpoint allows clients to query -/// the escrow hash associated with a specific instance for verification and tracking of bridge status. -/// -/// # Path Parameters -/// -/// - `instance_id`: UUID of the bridge instance to query -/// -/// # Returns -/// -/// - `200 OK`: Successfully returns escrow data information -/// - `500 Internal Server Error`: Server internal error or database operation failed -/// - Response includes instance ID, escrow hash (if present), and optional error information -/// -/// # Use Case -/// -/// Frontend applications use this endpoint to query the escrow hash for a bridge-out instance, to verify escrow -/// contract status and track bridge progress. -/// -/// # Example -/// -/// ```http -/// GET /v1/instances/123e4567-e89b-12d3-a456-426614174000/escrow-data -/// ``` -/// -/// Response example: -/// ```json -/// { -/// "instance_id": "123e4567-e89b-12d3-a456-426614174000", -/// "escrow": "0xf6d6523a4344806aca5c66f23554bc574cb93634572f5e115cc630b3d8db3c6e", -/// "error": null -/// } -/// ``` -#[axum::debug_handler] -pub async fn get_instance_escrow_data( - State(app_state): State>, - Path(instance_id): Path, -) -> ApiResult { - let instance_id = InputValidator::validate_uuid(&instance_id, "instance_id")?; - let mut storage_process = - app_state.local_db.acquire().await.api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")?; - match storage_process - .find_graph_goat_tx_record( - &instance_id, - &Uuid::nil(), - &GoatTxType::SwapInitialize.to_string(), - ) - .await - .api_error("PUT_BRIDGE_OUT_INIT_TAG_ERROR")? - { - Some(tx_record) => ok_response(EscrowDataResponse { - instance_id: instance_id.to_string(), - escrow: tx_record.extra, - error: None, - }), - None => ok_response(EscrowDataResponse { - instance_id: instance_id.to_string(), - escrow: None, - error: Some("no escrow record in db".to_string()), - }), - } -} - /// Get instance list /// -/// Returns a paginated list of bridge instances based on query parameters. Supports filtering by -/// source address and bridge direction (bridge-in or bridge-out). +/// Returns a paginated list of bridge-in instances based on query parameters. Supports filtering by +/// source address. /// /// # Query Parameters /// /// - `from_addr`: Source address filter (optional) - filters instances by source Bitcoin address -/// - `is_bridge_in`: Bridge direction filter (required) - true for bridge-in, false for bridge-out /// - `offset`: Pagination offset (default: 0) - number of items to skip /// - `limit`: Items per page (default: 10) - maximum number of items to return /// @@ -494,7 +101,7 @@ pub async fn get_instance_escrow_data( /// # Example /// /// ```http -/// GET /v1/instances?is_bridge_in=true&offset=0&limit=10 +/// GET /v1/instances?offset=0&limit=10 /// ``` /// /// Response example: @@ -504,7 +111,6 @@ pub async fn get_instance_escrow_data( /// { /// "instance": { /// "instance_id": "123e4567-e89b-12d3-a456-426614174000", -/// "is_bridge_in": true, /// "network": "testnet", /// "from_addr": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", /// "to_addr": "0x1234567890abcdef1234567890abcdef12345678", @@ -524,9 +130,7 @@ pub async fn get_instance_escrow_data( /// "committees_answers": {}, /// "pegin_data_tx_hash": "0x...", /// "parameters": null, -/// "escrow_hash": null, /// "status_updated_at": 1699123456, -/// "bridge_out_lock_time": 0, /// "created_at": 1699123456, /// "updated_at": 1699123456 /// }, @@ -564,19 +168,10 @@ pub async fn get_instances( let mut query = InstanceQuery::default(); if let Some(from_addr) = params.from_addr { - // Validate from_addr format (if provided) - let from_addr = if params.is_bridge_in { - InputValidator::validate_btc_address(&from_addr, None, "bridge in from_addr")?; - from_addr - } else { - InputValidator::validate_goat_address(&from_addr, "Bridge out from_addr")?.to_string() - }; + InputValidator::validate_btc_address(&from_addr, None, "bridge in from_addr")?; query = query.with_from_addr(from_addr); } - query = query - .with_pagination(offset, limit) - .with_order("created_at DESC".to_string()) - .with_is_bridge_in(params.is_bridge_in); + query = query.with_pagination(offset, limit).with_order("created_at DESC".to_string()); let (instances, total) = storage_process.find_instances(query).await.api_error("GET_INSTANCE_ERROR")?; @@ -585,8 +180,6 @@ pub async fn get_instances( warn!("get_instances instance is empty: total {}", total); return ok_response(InstanceListResponse::default()); } - let btc_current_height = - app_state.btc_client.get_height().await.api_error("GET_INSTANCE_ERROR")?; let response_window_blocks = app_state .goat_client .gateway_get_response_window_blocks() @@ -596,7 +189,6 @@ pub async fn get_instances( for instance in instances { let item = InstanceExtended::convert_from_instance( &app_state.btc_client, - btc_current_height, response_window_blocks as i64, instance, ) @@ -640,7 +232,6 @@ pub async fn get_instances( /// "instance_wrap": { /// "instance": { /// "instance_id": "123e4567-e89b-12d3-a456-426614174000", -/// "is_bridge_in": true, /// "network": "testnet", /// "from_addr": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", /// "to_addr": "0x1234567890abcdef1234567890abcdef12345678", @@ -660,8 +251,6 @@ pub async fn get_instances( /// "committees_answers": {}, /// "pegin_data_tx_hash": "0x...", /// "parameters": null, -/// "escrow_hash": null, -/// "bridge_out_lock_time": 0, /// "post_pegin_txhash": "0xf6d6523a4344806aca5c66f23554bc574cb93634572f5e115cc630b3d8db3c6e", /// "status_updated_at": 1699123456, /// "created_at": 1699123456, @@ -699,8 +288,6 @@ pub async fn get_instance( if let Some(instance) = storage_process.find_instance(&instance_id_uuid).await.api_error("GET_INSTANCE_ERROR")? { - let btc_current_height = - app_state.btc_client.get_height().await.api_error("GET_INSTANCE_ERROR")?; let response_window_blocks = app_state .goat_client .gateway_get_response_window_blocks() @@ -710,7 +297,6 @@ pub async fn get_instance( let instance_wrap = Some( InstanceExtended::convert_from_instance( &app_state.btc_client, - btc_current_height, response_window_blocks as i64, instance, ) @@ -770,7 +356,7 @@ pub async fn get_instances_overview( app_state.local_db.acquire().await.api_error("INSTANCE_OVERVIEW_ERROR")?; let (bridge_in_sum, bridge_in_count) = storage_process - .get_sum_bridge_txn(true, &[InstanceBridgeInStatus::RelayerL2Minted.to_string()]) + .get_sum_bridge_in_txn(&[InstanceBridgeInStatus::RelayerL2Minted.to_string()]) .await .api_error("INSTANCE_OVERVIEW_ERROR")?; diff --git a/node/src/rpc_service/handler/mod.rs b/node/src/rpc_service/handler/mod.rs index db8d4420e..b3dc15c0d 100644 --- a/node/src/rpc_service/handler/mod.rs +++ b/node/src/rpc_service/handler/mod.rs @@ -2,9 +2,11 @@ pub mod bitvm_handler; pub mod debug_handler; pub mod node_handler; pub mod proof_handler; +pub mod swap_handler; // Re-export all handler functions for better documentation visibility pub use bitvm_handler::*; pub use debug_handler::*; pub use node_handler::*; pub use proof_handler::*; +pub use swap_handler::*; diff --git a/node/src/rpc_service/handler/swap_handler.rs b/node/src/rpc_service/handler/swap_handler.rs new file mode 100644 index 000000000..d427a1d3f --- /dev/null +++ b/node/src/rpc_service/handler/swap_handler.rs @@ -0,0 +1,106 @@ +use crate::rpc_service::AppState; +use crate::rpc_service::response::{ApiErrorExt, ApiResult, ok_response}; +use crate::rpc_service::swap::*; +use crate::rpc_service::validation::InputValidator; +use axum::extract::{Path, Query, State}; +use std::sync::Arc; +use store::localdb::SwapEscrowQuery; +use store::normalize_escrow_hash; +use tracing::info; + +/// Get swap escrow list +/// +/// Returns a paginated list of swap-based bridge-out escrows, newest first. +/// +/// # Query Parameters +/// +/// - `from_addr`: offerer GOAT address filter (optional) +/// - `offset`: pagination offset (default: 0) +/// - `limit`: items per page (default: 10) +/// +/// # Example +/// +/// ```http +/// GET /v1/swaps?offset=0&limit=10 +/// ``` +#[axum::debug_handler] +pub async fn get_swaps( + Query(params): Query, + State(app_state): State>, +) -> ApiResult { + let (offset, limit) = InputValidator::validate_pagination(params.offset, params.limit)?; + let mut storage_process = app_state.local_db.acquire().await.api_error("GET_SWAPS_ERROR")?; + + let mut query = SwapEscrowQuery::default() + .with_order("created_at DESC".to_string()) + .with_pagination(offset, limit); + if let Some(from_addr) = params.from_addr { + let from_addr = InputValidator::validate_goat_address(&from_addr, "from_addr")?; + query = query.with_offerer_addr(from_addr); + } + + let (escrows, total) = + storage_process.find_swap_escrows(query).await.api_error("GET_SWAPS_ERROR")?; + if escrows.is_empty() { + return ok_response(SwapListResponse::default()); + } + + let btc_current_height = + app_state.btc_client.get_height().await.api_error("GET_SWAPS_ERROR")?; + let mut swaps = Vec::with_capacity(escrows.len()); + for escrow in escrows { + swaps.push( + SwapEscrowExtended::convert_from_swap_escrow( + &app_state.btc_client, + btc_current_height, + escrow, + ) + .await, + ); + } + ok_response(SwapListResponse { swaps, total }) +} + +/// Get swap escrow by escrow hash +/// +/// Returns the full record for one swap escrow, including the hex abi-encoded +/// EscrowData captured from its on-chain Initialize transaction (if the event +/// has been observed). +/// +/// # Path Parameters +/// +/// - `escrow_hash`: 0x-prefixed 32-byte hex escrow hash +/// +/// # Example +/// +/// ```http +/// GET /v1/swaps/0xf6d6523a4344806aca5c66f23554bc574cb93634572f5e115cc630b3d8db3c6e +/// ``` +#[axum::debug_handler] +pub async fn get_swap( + Path(escrow_hash): Path, + State(app_state): State>, +) -> ApiResult { + let escrow_hash = normalize_escrow_hash(&InputValidator::validate_hex( + &escrow_hash, + true, + 32, + "escrow_hash", + )?); + let mut storage_process = app_state.local_db.acquire().await.api_error("GET_SWAP_ERROR")?; + + let Some(escrow) = + storage_process.find_swap_escrow(&escrow_hash).await.api_error("GET_SWAP_ERROR")? + else { + info!("swap escrow {escrow_hash} has no record in database"); + return ok_response(SwapGetResponse { swap: None }); + }; + let btc_current_height = app_state.btc_client.get_height().await.api_error("GET_SWAP_ERROR")?; + let swap = SwapEscrowExtended::convert_from_swap_escrow( + &app_state.btc_client, + btc_current_height, + escrow, + ) + .await; + ok_response(SwapGetResponse { swap: Some(swap) }) +} diff --git a/node/src/rpc_service/mod.rs b/node/src/rpc_service/mod.rs index 2387b379f..70db84fd5 100644 --- a/node/src/rpc_service/mod.rs +++ b/node/src/rpc_service/mod.rs @@ -5,6 +5,7 @@ pub mod handler; mod node; mod response; pub mod routes; +mod swap; pub(super) mod utils; pub mod validation; @@ -12,18 +13,17 @@ use crate::env::{get_btc_url_from_env, get_goat_network, get_network, goat_confi use crate::metrics_service::{MetricsState, metrics_handler, metrics_middleware}; use crate::rpc_service::cors_config::CorsConfig; use crate::rpc_service::handler::{ - bridge_in_request_tag, bridge_out_init_tag, get_chain_proof_desc, get_debug_message_details, - get_debug_status, get_graph, get_graph_debug_messages, get_graph_neighbor_ids, get_graph_tx, - get_graph_txn, get_graphs, get_instance, get_instance_debug_messages, get_instance_escrow_data, - get_instances, get_instances_overview, get_node, get_nodes, get_nodes_overview, - get_operator_proof_desc, get_ready_to_kickoff_graph, get_unsigned_pegin_txn, instance_settings, - pegout, send_challenge, send_verifier_challenge, + get_chain_proof_desc, get_debug_message_details, get_debug_status, get_graph, + get_graph_debug_messages, get_graph_neighbor_ids, get_graph_tx, get_graph_txn, get_graphs, + get_instance, get_instance_debug_messages, get_instances, get_instances_overview, get_node, + get_nodes, get_nodes_overview, get_operator_proof_desc, get_ready_to_kickoff_graph, get_swap, + get_swaps, get_unsigned_pegin_txn, instance_settings, pegout, send_challenge, + send_verifier_challenge, }; use anyhow::Context; use axum::body::Body; use axum::extract::Request; use axum::response::Response; -use axum::routing::put; use axum::{ Router, middleware, routing::{get, post}, @@ -147,13 +147,12 @@ pub async fn serve_with_app_state( .route(routes::v1::NODES_BY_ID, get(get_node)) .route(routes::v1::NODES_OVERVIEW, get(get_nodes_overview)) .route(routes::v1::INSTANCES_SETTINGS, get(instance_settings)) - .route(routes::v1::INSTANCES_BRIDGE_IN_REQUEST_TAG, put(bridge_in_request_tag)) - .route(routes::v1::INSTANCES_BRIDGE_OUT_INIT_TAG, put(bridge_out_init_tag)) .route(routes::v1::INSTANCES_BASE, get(get_instances)) .route(routes::v1::INSTANCES_BY_ID, get(get_instance)) .route(routes::v1::INSTANCES_OVERVIEW, get(get_instances_overview)) .route(routes::v1::INSTANCES_UNSIGNED_PEGIN_TXN, get(get_unsigned_pegin_txn)) - .route(routes::v1::INSTANCES_ESCROW_DATA, get(get_instance_escrow_data)) + .route(routes::v1::SWAPS_BASE, get(get_swaps)) + .route(routes::v1::SWAPS_BY_ESCROW_HASH, get(get_swap)) .route(routes::v1::GRAPHS_BY_ID, get(get_graph)) .route(routes::v1::GRAPHS_BASE, get(get_graphs)) .route(routes::v1::GRAPHS_READY_TO_KICKOFF, get(get_ready_to_kickoff_graph)) @@ -278,7 +277,7 @@ mod tests { use prometheus_client::registry::Registry; use reqwest::Client; use secp256k1::Secp256k1; - use serde_json::{Value, json}; + use serde_json::Value; use std::str::FromStr; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -607,7 +606,6 @@ mod tests { let mut graphs = Vec::::new(); instances.push(Instance { instance_id: bridge_in_instance_id, - is_bridge_in: true, network: get_network().to_string(), from_addr: bridge_in_from.clone(), to_addr: bridge_in_to.clone(), @@ -627,10 +625,7 @@ mod tests { committees_answers: Default::default(), pegin_data_tx_hash: format!("0x{}", hex::encode(generate_random_bytes(32))), parameters: None, - escrow_hash: None, - bridge_out_lock_time: 0, post_pegin_txhash: None, - bridge_out_amount: "0".to_string(), status_updated_at: current_time_secs(), created_at: current_time_secs(), updated_at: current_time_secs(), @@ -638,7 +633,6 @@ mod tests { instances.push(Instance { instance_id: Uuid::new_v4(), - is_bridge_in: true, network: get_network().to_string(), from_addr: bridge_in_from.clone(), to_addr: bridge_in_to.clone(), @@ -658,11 +652,8 @@ mod tests { committees_answers: Default::default(), pegin_data_tx_hash: format!("0x{}", hex::encode(generate_random_bytes(32))), parameters: None, - escrow_hash: None, - bridge_out_lock_time: 0, post_pegin_txhash: None, status_updated_at: current_time_secs(), - bridge_out_amount: "0".to_string(), created_at: current_time_secs(), updated_at: current_time_secs(), }); @@ -757,8 +748,6 @@ mod tests { )); sleep(Duration::from_secs(3)).await; - let bridge_in_request_tag_id = Uuid::new_v4(); - let target_instance_id = bridge_in_instance_id; let api_test_items = vec![ ApiTestItem { @@ -774,32 +763,6 @@ mod tests { ) })), }, - ApiTestItem { - tag: routes::v1::INSTANCES_BRIDGE_IN_REQUEST_TAG.to_string(), - url: format!("http://{addr}{}", routes::v1::INSTANCES_BRIDGE_IN_REQUEST_TAG), - json_payload: Some(json!({ - "instance_id": bridge_in_request_tag_id, - "contract_address": "0x21f619040AC2eAcacEF8Fe17Ae8bDF53ec69C66f", - "bridge_request_tx_hash": format!("0x{}", hex::encode(generate_random_bytes(32))), - "from_addr": get_rand_btc_address_p2wpkh(get_network()), - "to_addr": format!("0x{}", hex::encode(generate_random_bytes(20))) - })), - method: Method::PUT, - expe_res: true, - resp_validation: None, - }, - ApiTestItem { - tag: format!("{} for bridge in request tag", routes::v1::INSTANCES_BY_ID), - url: format!( - "http://{addr}{}/{}", - routes::v1::INSTANCES_BASE, - bridge_in_request_tag_id - ), - json_payload: None, - method: Method::GET, - expe_res: true, - resp_validation: None, - }, ApiTestItem { tag: routes::v1::INSTANCES_BY_ID.to_string(), url: format!( @@ -824,7 +787,7 @@ mod tests { ApiTestItem { tag: format!("{} get instances", routes::v1::INSTANCES_BASE), url: format!( - "http://{addr}{}?is_bridge_in=true&from_addr={}", + "http://{addr}{}?from_addr={}", routes::v1::INSTANCES_BASE, bridge_in_from, ), diff --git a/node/src/rpc_service/routes.rs b/node/src/rpc_service/routes.rs index 62d75ad97..5a6a5f446 100644 --- a/node/src/rpc_service/routes.rs +++ b/node/src/rpc_service/routes.rs @@ -8,13 +8,13 @@ pub(crate) mod v1 { pub const INSTANCES_BASE: &str = "/v1/instances"; pub const INSTANCES_SETTINGS: &str = "/v1/instances/settings"; - pub const INSTANCES_BRIDGE_IN_REQUEST_TAG: &str = "/v1/instances/bridge-in-request-tag"; - pub const INSTANCES_BRIDGE_OUT_INIT_TAG: &str = "/v1/instances/bridge-out-init-tag"; pub const INSTANCES_BY_ID: &str = "/v1/instances/{:id}"; pub const INSTANCES_OVERVIEW: &str = "/v1/instances/overview"; // TODO(auth): Restrict access before returning locally constructed cancellation PSBTs. pub const INSTANCES_UNSIGNED_PEGIN_TXN: &str = "/v1/instances/{:id}/unsigned-pegin-txn"; - pub const INSTANCES_ESCROW_DATA: &str = "/v1/instances/{:id}/escrow-data"; + + pub const SWAPS_BASE: &str = "/v1/swaps"; + pub const SWAPS_BY_ESCROW_HASH: &str = "/v1/swaps/{:escrow_hash}"; pub const GRAPHS_BASE: &str = "/v1/graphs"; pub const GRAPHS_BY_ID: &str = "/v1/graphs/{:id}"; pub const GRAPHS_READY_TO_KICKOFF: &str = "/v1/graphs/ready-to-kickoff"; diff --git a/node/src/rpc_service/swap.rs b/node/src/rpc_service/swap.rs new file mode 100644 index 000000000..c00b9789c --- /dev/null +++ b/node/src/rpc_service/swap.rs @@ -0,0 +1,139 @@ +use super::utils::{deserialize_u256, serialize_u256}; +use crate::rpc_service::bitvm::{StatusExtra, StatusUserAction}; +use crate::rpc_service::current_time_secs; +use alloy::primitives::U256; +use client::btc_chain::BTCClient; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use store::{SerializableTxid, SwapEscrow, SwapEscrowStatus}; + +const SWAP_CLAIM_BTC_TARGET_CONFIRMATIONS: u32 = 6; +const SWAP_TIMEOUT_ERROR: &str = "The operation timed out. Please try again."; + +#[derive(Debug, Deserialize)] +pub struct SwapListRequest { + pub from_addr: Option, // offerer goat address + pub offset: Option, + pub limit: Option, +} + +#[derive(Deserialize, Serialize, Default)] +pub struct SwapEscrowDisplay { + pub escrow_hash: String, + pub network: String, + pub status: String, + pub from_addr: String, // offerer goat address + pub to_addr: String, // btc payout address + pub claimer_addr: String, // goat address entitled to claim + pub token: String, + #[serde(serialize_with = "serialize_u256", deserialize_with = "deserialize_u256")] + pub amount: U256, + pub refund_deadline: i64, + pub init_tx_hash: String, + pub init_tx_height: i64, + pub claim_tx_hash: String, + pub claim_btc_txid: Option, + pub refund_tx_hash: String, + pub status_updated_at: i64, + pub created_at: i64, + pub updated_at: i64, +} + +impl From for SwapEscrowDisplay { + fn from(escrow: SwapEscrow) -> Self { + Self { + escrow_hash: escrow.escrow_hash, + network: escrow.network, + status: escrow.status, + from_addr: escrow.offerer_addr, + to_addr: escrow.btc_addr, + claimer_addr: escrow.claimer_addr, + token: escrow.token, + amount: U256::from_str(&escrow.amount).unwrap_or_default(), + refund_deadline: escrow.refund_deadline, + init_tx_hash: escrow.init_tx_hash, + init_tx_height: escrow.init_tx_height, + claim_tx_hash: escrow.claim_tx_hash, + claim_btc_txid: escrow.claim_btc_txid, + refund_tx_hash: escrow.refund_tx_hash, + status_updated_at: escrow.status_updated_at, + created_at: escrow.created_at, + updated_at: escrow.updated_at, + } + } +} + +#[derive(Deserialize, Serialize, Default)] +pub struct SwapEscrowExtended { + pub swap: SwapEscrowDisplay, + /// Hex abi-encoded EscrowData captured from the Initialize tx, if seen. + pub escrow_data: Option, + pub waiting_time_in_secs: i64, + pub confirmations: u32, + pub target_confirmations: u32, + pub status_extra: StatusExtra, +} + +impl SwapEscrowExtended { + pub async fn convert_from_swap_escrow( + btc_client: &BTCClient, + btc_current_height: u32, + mut escrow: SwapEscrow, + ) -> Self { + let (confirmations, target_confirmations) = + get_claim_btc_confirm_progress(btc_client, btc_current_height, &escrow.claim_btc_txid) + .await; + Self { + waiting_time_in_secs: get_swap_waiting_time(&escrow.status, escrow.refund_deadline), + confirmations, + target_confirmations, + status_extra: get_swap_status_extra(&escrow.status), + escrow_data: escrow.escrow_data.take(), + swap: escrow.into(), + } + } +} + +/// Seconds until the refund deadline while the escrow is still initializing. +fn get_swap_waiting_time(status: &str, refund_deadline: i64) -> i64 { + match SwapEscrowStatus::from_str(status) { + Ok(SwapEscrowStatus::Initialize) => (refund_deadline - current_time_secs()).max(0), + _ => 0, + } +} + +fn get_swap_status_extra(status: &str) -> StatusExtra { + let mut status_extra = StatusExtra::default(); + if matches!(SwapEscrowStatus::from_str(status), Ok(SwapEscrowStatus::Timeout)) { + status_extra.is_failed = true; + status_extra.error = Some(SWAP_TIMEOUT_ERROR.to_string()); + status_extra.user_action = StatusUserAction::Refund; + } + status_extra +} + +async fn get_claim_btc_confirm_progress( + btc_client: &BTCClient, + current_height: u32, + claim_btc_txid: &Option, +) -> (u32, u32) { + if let Some(txid) = claim_btc_txid + && let Ok(tx_status) = btc_client.get_tx_status(&txid.0).await + && let Some(height) = tx_status.block_height + { + (current_height + 1 - height, SWAP_CLAIM_BTC_TARGET_CONFIRMATIONS) + } else { + (0, SWAP_CLAIM_BTC_TARGET_CONFIRMATIONS) + } +} + +#[derive(Deserialize, Serialize, Default)] +pub struct SwapListResponse { + pub swaps: Vec, + pub total: i64, +} + +#[derive(Deserialize, Serialize)] +pub struct SwapGetResponse { + pub swap: Option, +} diff --git a/node/src/scheduled_tasks/event_watch_task.rs b/node/src/scheduled_tasks/event_watch_task.rs index e631c2689..2fd39bc98 100644 --- a/node/src/scheduled_tasks/event_watch_task.rs +++ b/node/src/scheduled_tasks/event_watch_task.rs @@ -9,15 +9,14 @@ use crate::env::{ use crate::metrics_service::{EventWatchState, MetricsState}; use crate::rpc_service::current_time_secs; use crate::scheduled_tasks::get_timestamp_from_contract_data; -use crate::utils::evm_swap_utils::IEscrowManager::EscrowData; use crate::utils::evm_swap_utils::{extract_claim_data_from_tx, extract_escrow_data_from_tx}; use crate::utils::{ - GenerateInstanceParams, bridge_out_instance_id_from_escrow_hash, find_instances_by_escrow_hash, - generate_instance, get_bridge_out_global_stats, obsolete_instance_graphs_except, - outpoint_available, reflect_goat_address, strip_hex_prefix_owned, + GenerateInstanceParams, generate_instance, get_bridge_out_global_stats, + obsolete_instance_graphs_except, outpoint_available, reflect_goat_address, + strip_hex_prefix_owned, }; use alloy::primitives::{Address as EvmAddress, U256}; -use alloy::sol_types::{SolType, SolValue}; +use alloy::sol_types::SolValue; use bitcoin::address::NetworkUnchecked; use bitcoin::hashes::Hash; use bitcoin::{Address, Amount, OutPoint, Txid}; @@ -41,11 +40,13 @@ use std::ops::AddAssign; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; -use store::localdb::{GraphRuntimeUpdate, InstanceUpdate, LocalDB, NodeQuery, StorageProcessor}; +use store::localdb::{ + GraphRuntimeUpdate, InstanceUpdate, LocalDB, NodeQuery, StorageProcessor, SwapEscrowUpdate, +}; use store::{ GoatTxProcessingStatus, GoatTxRecord, GoatTxType, GraphStatus, GraphStatusSource, - GraphStatusTransitionOutcome, Instance, InstanceBridgeInStatus, InstanceBridgeOutStatus, - MessageState, WatchContract, WatchContractStatus, + GraphStatusTransitionOutcome, Instance, InstanceBridgeInStatus, MessageState, SwapEscrow, + SwapEscrowStatus, WatchContract, WatchContractStatus, normalize_escrow_hash, }; use tokio::time::sleep; use tokio_util::sync::CancellationToken; @@ -785,7 +786,7 @@ async fn handle_swap_init_events<'a>( init_events: Vec, ) -> anyhow::Result<()> { for event in init_events { - if let Some(escrow_data) = extract_escrow_data_from_tx( + let Some(escrow_data) = extract_escrow_data_from_tx( &goat_client, &event.transaction_hash, swap_contract_address, @@ -794,152 +795,59 @@ async fn handle_swap_init_events<'a>( )?, ) .await? - { - let create_time = event.block_timestamp.parse::()?; - let event_height = event.block_number.parse::()?; - let (instance_id, initialized) = if let Some(instance) = - find_instances_by_escrow_hash(storage_processor, &event.escrow_hash).await? - { - let initialized = storage_processor - .update_instance( - &swap_init_instance_update( - instance.instance_id, - &escrow_data, - &event, - event_height, - ) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false) - .with_only_if_goat_tx_hash(String::new()), - ) - .await?; - (instance.instance_id, initialized) - } else { - let instance_id = bridge_out_instance_id_from_escrow_hash(&event.escrow_hash); - let instance = Instance { - instance_id, - is_bridge_in: false, - network: get_network().to_string(), - from_addr: escrow_data.offerer.to_string(), - input_utxos: "[]".to_string(), - status: InstanceBridgeOutStatus::Initialize.to_string(), - escrow_hash: Some(event.escrow_hash.clone()), - bridge_out_amount: escrow_data.amount.to_string(), - goat_tx_hash: event.transaction_hash.clone(), - goat_tx_height: event_height, - user_change_addr: escrow_data.claimer.to_string(), - user_refund_addr: escrow_data.claimer.to_string(), - bridge_out_lock_time: get_timestamp_from_contract_data( - &escrow_data.refundData.0, - ), - status_updated_at: create_time, - created_at: create_time, - ..Default::default() - }; - let initialized = if storage_processor.insert_instance_if_absent(&instance).await? { - true - } else if let Some(existing) = storage_processor.find_instance(&instance_id).await? - { - let escrow_hash_matches = existing - .escrow_hash - .as_ref() - .map(|hash| hash.eq_ignore_ascii_case(&event.escrow_hash)) - .unwrap_or(false); - if existing.is_bridge_in || !escrow_hash_matches { - anyhow::bail!( - "bridge-out instance ID collision for escrow hash {}", - event.escrow_hash - ); - } - storage_processor - .update_instance( - &swap_init_instance_update( - existing.instance_id, - &escrow_data, - &event, - event_height, - ) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false) - .with_only_if_goat_tx_hash(String::new()), - ) - .await? - } else { - anyhow::bail!( - "bridge-out instance {} disappeared during creation", - instance_id - ); - }; - (instance_id, initialized) - }; - if initialized && escrow_data.token == *gateway_peg_btc_address { - let mut bridge_out_global_stats = - get_bridge_out_global_stats(storage_processor).await?; - let mut initial_amount = - U256::from_str(&bridge_out_global_stats.initial_amount).unwrap_or_default(); - initial_amount.add_assign(&escrow_data.amount); - bridge_out_global_stats.initial_amount = initial_amount.to_string(); - bridge_out_global_stats.initial_txn += 1; - storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; - info!( - "swap initialize stats included: tx_hash={}, escrow_hash={}, token={}, amount={}", - event.transaction_hash, - event.escrow_hash, - escrow_data.token, - escrow_data.amount, - ); - } else if initialized { - info!( - "swap initialize stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}, token={}, expect_token={}", - event.transaction_hash, - event.escrow_hash, - escrow_data.token, - gateway_peg_btc_address, - ); - } else { - info!( - "swap initialize ignored for resolved or previously initialized instance {instance_id}" - ); - } - storage_processor - .upsert_goat_tx_record(&GoatTxRecord { - instance_id, - graph_id: Uuid::nil(), - tx_type: GoatTxType::SwapInitialize.to_string(), - tx_hash: event.transaction_hash.clone(), - height: event_height, - is_local: false, - processing_status: GoatTxProcessingStatus::Skipped.to_string(), - extra: Some(hex::encode(escrow_data.abi_encode())), - created_at: create_time, - }) - .await?; - } else { + else { warn!("failed to parse escrow_data for event:{event:?}"); + continue; + }; + let escrow_hash = normalize_escrow_hash(&event.escrow_hash); + let create_time = event.block_timestamp.parse::()?; + let event_height = event.block_number.parse::()?; + let escrow = SwapEscrow { + escrow_hash: escrow_hash.clone(), + network: get_network().to_string(), + status: SwapEscrowStatus::Initialize.to_string(), + offerer_addr: escrow_data.offerer.to_string(), + claimer_addr: escrow_data.claimer.to_string(), + token: escrow_data.token.to_string(), + amount: escrow_data.amount.to_string(), + refund_deadline: get_timestamp_from_contract_data(&escrow_data.refundData.0), + escrow_data: Some(hex::encode(escrow_data.abi_encode())), + init_tx_hash: event.transaction_hash.clone(), + init_tx_height: event_height, + status_updated_at: create_time, + created_at: create_time, + ..Default::default() + }; + // Swap records are derived exclusively from the on-chain Initialize + // event; duplicate event scans must not overwrite the first record. + let initialized = storage_processor.insert_swap_escrow_if_absent(&escrow).await?; + if initialized && escrow_data.token == *gateway_peg_btc_address { + let mut bridge_out_global_stats = + get_bridge_out_global_stats(storage_processor).await?; + let mut initial_amount = + U256::from_str(&bridge_out_global_stats.initial_amount).unwrap_or_default(); + initial_amount.add_assign(&escrow_data.amount); + bridge_out_global_stats.initial_amount = initial_amount.to_string(); + bridge_out_global_stats.initial_txn += 1; + storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; + info!( + "swap initialize stats included: tx_hash={}, escrow_hash={}, token={}, amount={}", + event.transaction_hash, escrow_hash, escrow_data.token, escrow_data.amount, + ); + } else if initialized { + info!( + "swap initialize stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}, token={}, expect_token={}", + event.transaction_hash, escrow_hash, escrow_data.token, gateway_peg_btc_address, + ); + } else { + info!( + "swap initialize ignored for resolved or previously initialized escrow {escrow_hash}" + ); } } Ok(()) } -fn swap_init_instance_update( - instance_id: Uuid, - escrow_data: &EscrowData, - event: &SwapInitializeEvent, - event_height: i64, -) -> InstanceUpdate { - InstanceUpdate::new_with_instance_id(instance_id) - .with_bridge_out_amount(escrow_data.amount.to_string()) - .with_goat_tx_hash(event.transaction_hash.clone()) - .with_goat_tx_height(event_height) - .with_user_change_addr(escrow_data.claimer.to_string()) - .with_user_refund_addr(escrow_data.claimer.to_string()) - .with_bridge_out_lock_time(get_timestamp_from_contract_data(&escrow_data.refundData.0)) -} - async fn handle_swap_claim_events<'a>( storage_processor: &mut StorageProcessor<'a>, goat_client: Arc, @@ -948,7 +856,7 @@ async fn handle_swap_claim_events<'a>( claim_events: Vec, ) -> anyhow::Result<()> { for event in claim_events { - if let Some(claim_data) = extract_claim_data_from_tx( + let Some(claim_data) = extract_claim_data_from_tx( &goat_client, &event.transaction_hash, swap_contract_address, @@ -957,78 +865,73 @@ async fn handle_swap_claim_events<'a>( )?, ) .await? - && let Some(instance) = - find_instances_by_escrow_hash(storage_processor, &event.escrow_hash).await? - { - let instance_id = instance.instance_id; - let to_addr = Address::from_script( - bitcoin::Script::from_bytes(&claim_data.output_script), - get_network(), - )?; - let transitioned = storage_processor - .update_instance( - &InstanceUpdate::new_with_instance_id(instance_id) - .with_status(InstanceBridgeOutStatus::Claim.to_string()) - .with_btc_txid(claim_data.txid.into()) - .with_to_addr(to_addr.to_string()) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false), - ) - .await?; - if !transitioned { - info!( - "swap claim ignored for resolved instance {instance_id} with status {}", - instance.status - ); - continue; - } - storage_processor - .upsert_goat_tx_record(&GoatTxRecord { - instance_id, - graph_id: Uuid::nil(), - tx_type: GoatTxType::SwapClaim.to_string(), - tx_hash: event.transaction_hash.clone(), - height: event.block_number.parse::()?, - is_local: false, - processing_status: GoatTxProcessingStatus::Skipped.to_string(), - extra: Some(claim_data.witness), - created_at: current_time_secs(), - }) - .await?; - - let is_peg_btc_swap = is_gateway_peg_btc_swap_instance( - storage_processor, - &instance_id, - gateway_peg_btc_address, + else { + warn!("failed to parse claim_data for event:{event:?}"); + continue; + }; + let escrow_hash = normalize_escrow_hash(&event.escrow_hash); + let Some(escrow) = storage_processor.find_swap_escrow(&escrow_hash).await? else { + warn!( + "swap claim ignored for unknown escrow {escrow_hash}, tx_hash={}", + event.transaction_hash + ); + continue; + }; + let btc_addr = Address::from_script( + bitcoin::Script::from_bytes(&claim_data.output_script), + get_network(), + ) + .map(|address| address.to_string()) + .unwrap_or_else(|error| { + warn!( + escrow_hash = %escrow_hash, + tx_hash = %event.transaction_hash, + output_script = %hex::encode(&claim_data.output_script), + %error, + "swap claim output script is not a Bitcoin address; storing an empty display address" + ); + String::new() + }); + // The claim event is authoritative: it also overrides a locally + // derived Timeout. + let transitioned = storage_processor + .update_swap_escrow( + &SwapEscrowUpdate::new(escrow_hash.clone()) + .with_status(SwapEscrowStatus::Claim.to_string()) + .with_claim_tx_hash(event.transaction_hash.clone()) + .with_claim_btc_txid(claim_data.txid.into()) + .with_btc_addr(btc_addr) + .with_only_if_status_in(vec![ + SwapEscrowStatus::Initialize.to_string(), + SwapEscrowStatus::Timeout.to_string(), + ]), ) .await?; - if is_peg_btc_swap { - let mut bridge_out_global_stats = - get_bridge_out_global_stats(storage_processor).await?; - let mut claim_amount = - U256::from_str(&bridge_out_global_stats.claim_amount).unwrap_or_default(); - claim_amount - .add_assign(&U256::from_str(&instance.bridge_out_amount).unwrap_or_default()); - bridge_out_global_stats.claim_amount = claim_amount.to_string(); - bridge_out_global_stats.claim_txn += 1; - storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; - info!( - "swap claim stats included: tx_hash={}, escrow_hash={}, instance_id={}, amount={}", - event.transaction_hash, - event.escrow_hash, - instance_id, - instance.bridge_out_amount, - ); - } else { - info!( - "swap claim stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}, instance_id={}", - event.transaction_hash, event.escrow_hash, instance_id, - ); - } + if !transitioned { + info!( + "swap claim ignored for resolved escrow {escrow_hash} with status {}", + escrow.status + ); + continue; + } + if escrow.token == gateway_peg_btc_address.to_string() { + let mut bridge_out_global_stats = + get_bridge_out_global_stats(storage_processor).await?; + let mut claim_amount = + U256::from_str(&bridge_out_global_stats.claim_amount).unwrap_or_default(); + claim_amount.add_assign(&U256::from_str(&escrow.amount).unwrap_or_default()); + bridge_out_global_stats.claim_amount = claim_amount.to_string(); + bridge_out_global_stats.claim_txn += 1; + storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; + info!( + "swap claim stats included: tx_hash={}, escrow_hash={}, amount={}", + event.transaction_hash, escrow_hash, escrow.amount, + ); } else { - warn!("failed to parse claim_data for event:{event:?}"); + info!( + "swap claim stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}", + event.transaction_hash, escrow_hash, + ); } } Ok(()) @@ -1040,82 +943,57 @@ async fn handle_swap_refund_events<'a>( refund_events: Vec, ) -> anyhow::Result<()> { for event in refund_events { - if let Some(instance) = - find_instances_by_escrow_hash(storage_processor, &event.escrow_hash).await? - { - let transitioned = storage_processor - .update_instance( - &InstanceUpdate::new_with_instance_id(instance.instance_id) - .with_status(InstanceBridgeOutStatus::Refund.to_string()) - .with_only_if_status_in(vec![ - InstanceBridgeOutStatus::Initialize.to_string(), - ]) - .with_only_if_is_bridge_in(false), - ) - .await?; - if !transitioned { - info!( - "swap refund ignored for resolved instance {} with status {}", - instance.instance_id, instance.status - ); - continue; - } - let is_peg_btc_swap = is_gateway_peg_btc_swap_instance( - storage_processor, - &instance.instance_id, - gateway_peg_btc_address, + let escrow_hash = normalize_escrow_hash(&event.escrow_hash); + let Some(escrow) = storage_processor.find_swap_escrow(&escrow_hash).await? else { + warn!( + "swap refund ignored for unknown escrow {escrow_hash}, tx_hash={}", + event.transaction_hash + ); + continue; + }; + // The refund event is authoritative: it also overrides a locally + // derived Timeout. + let transitioned = storage_processor + .update_swap_escrow( + &SwapEscrowUpdate::new(escrow_hash.clone()) + .with_status(SwapEscrowStatus::Refund.to_string()) + .with_refund_tx_hash(event.transaction_hash.clone()) + .with_only_if_status_in(vec![ + SwapEscrowStatus::Initialize.to_string(), + SwapEscrowStatus::Timeout.to_string(), + ]), ) .await?; - if is_peg_btc_swap { - let mut bridge_out_global_stats = - get_bridge_out_global_stats(storage_processor).await?; - let mut refund_amount = - U256::from_str(&bridge_out_global_stats.refund_amount).unwrap_or_default(); - refund_amount - .add_assign(&U256::from_str(&instance.bridge_out_amount).unwrap_or_default()); - bridge_out_global_stats.refund_amount = refund_amount.to_string(); - bridge_out_global_stats.refund_txn += 1; - storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; - info!( - "swap refund stats included: tx_hash={}, escrow_hash={}, instance_id={}, amount={}", - event.transaction_hash, - event.escrow_hash, - instance.instance_id, - instance.bridge_out_amount, - ); - } else { - info!( - "swap refund stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}, instance_id={}", - event.transaction_hash, event.escrow_hash, instance.instance_id, - ); - } + if !transitioned { + info!( + "swap refund ignored for resolved escrow {escrow_hash} with status {}", + escrow.status + ); + continue; + } + if escrow.token == gateway_peg_btc_address.to_string() { + let mut bridge_out_global_stats = + get_bridge_out_global_stats(storage_processor).await?; + let mut refund_amount = + U256::from_str(&bridge_out_global_stats.refund_amount).unwrap_or_default(); + refund_amount.add_assign(&U256::from_str(&escrow.amount).unwrap_or_default()); + bridge_out_global_stats.refund_amount = refund_amount.to_string(); + bridge_out_global_stats.refund_txn += 1; + storage_processor.upsert_bridge_out_global_stats(&bridge_out_global_stats).await?; + info!( + "swap refund stats included: tx_hash={}, escrow_hash={}, amount={}", + event.transaction_hash, escrow_hash, escrow.amount, + ); + } else { + info!( + "swap refund stats skipped(non-pegBTC): tx_hash={}, escrow_hash={}", + event.transaction_hash, escrow_hash, + ); } } Ok(()) } -async fn is_gateway_peg_btc_swap_instance( - storage_processor: &mut StorageProcessor<'_>, - instance_id: &Uuid, - gateway_peg_btc_address: &EvmAddress, -) -> anyhow::Result { - if let Some(tx_record) = storage_processor - .find_graph_goat_tx_record( - instance_id, - &Uuid::nil(), - &GoatTxType::SwapInitialize.to_string(), - ) - .await? - && let Some(encode_escrow_data) = tx_record.extra - && let Ok(escrow_data_bytes) = hex::decode(&encode_escrow_data) - && let Ok(escrow_data) = ::abi_decode(&escrow_data_bytes) - { - return Ok(escrow_data.token == *gateway_peg_btc_address); - } - - Ok(false) -} - pub(super) async fn generate_instance_from_bridge_in_request_event( btc_client: &BTCClient, goat_client: &GOATClient, diff --git a/node/src/scheduled_tasks/instance_maintenance_tasks.rs b/node/src/scheduled_tasks/instance_maintenance_tasks.rs index e5195e941..0d367d9cd 100644 --- a/node/src/scheduled_tasks/instance_maintenance_tasks.rs +++ b/node/src/scheduled_tasks/instance_maintenance_tasks.rs @@ -9,15 +9,12 @@ use crate::env::{ }; use crate::rpc_service::current_time_secs; use crate::scheduled_tasks::event_watch_task::generate_instance_from_bridge_in_request_event; -use crate::scheduled_tasks::get_timestamp_from_contract_data; -use crate::utils::evm_swap_utils::IEscrowManager::EscrowData; use crate::utils::{ SELF_SENDER, check_bridge_in_uxto_available_or_self_spent, gen_instance_parameters_local, get_committee_endorse_sigs_for_pegin, get_committee_partial_sig_for_instance, get_committee_pub_nonce_for_instance, load_committee_instance_keypair, store_committee_pub_nonce_for_instance, upsert_message, }; -use alloy::sol_types::SolType; use bitvm_lib::actors::Actor; use bitvm_lib::keys::CommitteeMasterKey; use bitvm_lib::timelocks::default_connector_z_timelock_blocks; @@ -33,16 +30,13 @@ use std::str::FromStr; use std::sync::{LazyLock, Mutex}; use std::vec; use store::localdb::{InstanceQuery, InstanceUpdate, LocalDB, StorageProcessor}; -use store::{ - GoatTxProcessingStatus, GoatTxType, Instance, InstanceBridgeInStatus, InstanceBridgeOutStatus, -}; +use store::{GoatTxProcessingStatus, GoatTxType, Instance, InstanceBridgeInStatus}; use tracing::{info, warn}; use uuid::Uuid; const TASK_KEY_INSTANCE_WINDOW_EXPIRATION: &str = "instance_window_expiration_monitor"; const TASK_KEY_INSTANCE_EXPIRATION: &str = "instance_expiration_monitor"; const TASK_KEY_INSTANCE_BTC_TX: &str = "instance_btc_tx_monitor"; -const TASK_KEY_INSTANCE_BRIDGE_OUT: &str = "instance_bridge_out_monitor"; const TASK_KEY_PEGIN_CONFIRM_RECOVERY: &str = "pegin_confirm_recovery_monitor"; #[derive(Clone, Debug)] @@ -242,7 +236,6 @@ pub async fn instance_window_expiration_monitor( local_db, TASK_KEY_INSTANCE_WINDOW_EXPIRATION, InstanceQuery::default() - .with_is_bridge_in(true) .with_status(InstanceBridgeInStatus::UserInited.to_string()) .with_pegin_request_height_threshold(current_height - window_blocks), batch_size, @@ -303,8 +296,7 @@ pub async fn instance_window_expiration_monitor( let mut update = InstanceUpdate::new_with_instance_id(instance.instance_id) .with_status(next_status.to_string()) .with_committees_answers(instance.committees_answers.clone()) - .with_only_if_status_in(vec![InstanceBridgeInStatus::UserInited.to_string()]) - .with_only_if_is_bridge_in(true); + .with_only_if_status_in(vec![InstanceBridgeInStatus::UserInited.to_string()]); if reached_quorum { if let Some(btc_txid) = instance.btc_txid.clone() { update = update.with_btc_txid(btc_txid); @@ -357,7 +349,7 @@ pub async fn instance_expiration_monitor( find_one_instance_page( local_db, TASK_KEY_INSTANCE_EXPIRATION, - InstanceQuery::default().with_is_bridge_in(true).with_statuses(vec![ + InstanceQuery::default().with_statuses(vec![ InstanceBridgeInStatus::Presigned.to_string(), InstanceBridgeInStatus::PresignedFailed.to_string(), ]), @@ -394,7 +386,7 @@ pub async fn instance_btc_tx_monitor( let instances = find_one_instance_page( local_db, TASK_KEY_INSTANCE_BTC_TX, - InstanceQuery::default().with_is_bridge_in(true).with_statuses(vec![ + InstanceQuery::default().with_statuses(vec![ InstanceBridgeInStatus::UserInited.to_string(), InstanceBridgeInStatus::CommitteesAnswered.to_string(), InstanceBridgeInStatus::Presigned.to_string(), @@ -527,9 +519,7 @@ pub async fn pegin_confirm_recovery_monitor( let instances = find_one_instance_page( local_db, TASK_KEY_PEGIN_CONFIRM_RECOVERY, - InstanceQuery::default() - .with_is_bridge_in(true) - .with_status(InstanceBridgeInStatus::Presigned.to_string()), + InstanceQuery::default().with_status(InstanceBridgeInStatus::Presigned.to_string()), get_instance_maintenance_batch_size(), ) .await?; @@ -668,64 +658,14 @@ pub async fn pegin_confirm_recovery_monitor( Ok(()) } -pub async fn get_bridge_out_deadline<'a>( - storage_processor: &mut StorageProcessor<'a>, - instance_id: &Uuid, -) -> anyhow::Result { - let deadline = if let Some(tx_record) = storage_processor - .find_graph_goat_tx_record( - instance_id, - &Uuid::nil(), - &GoatTxType::SwapInitialize.to_string(), - ) - .await? - && let Some(encode_escrow_data) = tx_record.extra - && let Ok(escrow_data_bytes) = hex::decode(&encode_escrow_data) - && let Ok(escrow_data) = EscrowData::abi_decode(&escrow_data_bytes) - { - get_timestamp_from_contract_data(&escrow_data.refundData.0) - } else { - 0 - }; - Ok(deadline) -} - -pub async fn instance_bridge_out_monitor(local_db: &LocalDB) -> anyhow::Result<()> { - let batch_size = get_instance_maintenance_batch_size(); - let current_time = current_time_secs(); - let instances = find_one_instance_page( - local_db, - TASK_KEY_INSTANCE_BRIDGE_OUT, - InstanceQuery::default() - .with_is_bridge_in(false) - .with_status(InstanceBridgeOutStatus::Initialize.to_string()) - .with_raw_condition("escrow_hash IS NOT NULL".to_string()), - batch_size, - ) - .await?; +/// Mark still-initializing swap escrows whose refund deadline has passed as +/// timed out. +pub async fn swap_escrow_timeout_monitor(local_db: &LocalDB) -> anyhow::Result<()> { let mut storage_processor = local_db.acquire().await?; - for instance in instances { - let mut instance_update = InstanceUpdate::new_with_instance_id(instance.instance_id) - .with_only_if_status_in(vec![InstanceBridgeOutStatus::Initialize.to_string()]) - .with_only_if_is_bridge_in(false); - let lock_time = if instance.bridge_out_lock_time == 0 { - let lock_time = - get_bridge_out_deadline(&mut storage_processor, &instance.instance_id).await?; - instance_update = instance_update.with_bridge_out_lock_time(lock_time); - lock_time - } else { - instance.bridge_out_lock_time - }; - if lock_time < current_time && lock_time > 0 { - instance_update = - instance_update.with_status(InstanceBridgeOutStatus::Timeout.to_string()); - } - - if instance_update.has_updates() { - storage_processor.update_instance(&instance_update).await?; - } + let timed_out = storage_processor.timeout_expired_swap_escrows(current_time_secs()).await?; + if timed_out > 0 { + info!("marked {timed_out} swap escrows as timed out"); } - Ok(()) } diff --git a/node/src/scheduled_tasks/mod.rs b/node/src/scheduled_tasks/mod.rs index fd04fa343..ea64ff2f6 100644 --- a/node/src/scheduled_tasks/mod.rs +++ b/node/src/scheduled_tasks/mod.rs @@ -18,9 +18,9 @@ use crate::scheduled_tasks::graph_maintenance_tasks::{ detect_init_withdraw_call, detect_kickoff, detect_take1_or_challenge, process_graph_challenge, }; use crate::scheduled_tasks::instance_maintenance_tasks::{ - instance_answers_monitor, instance_bridge_out_monitor, instance_btc_tx_monitor, - instance_committee_key_cleanup_monitor, instance_expiration_monitor, - instance_window_expiration_monitor, pegin_confirm_recovery_monitor, + instance_answers_monitor, instance_btc_tx_monitor, instance_committee_key_cleanup_monitor, + instance_expiration_monitor, instance_window_expiration_monitor, + pegin_confirm_recovery_monitor, swap_escrow_timeout_monitor, }; use crate::scheduled_tasks::node_maintenance_tasks::node_available_pbtc_update_monitor; use crate::scheduled_tasks::spv_maintenance_tasks::spv_header_hash_update; @@ -312,8 +312,8 @@ async fn run( .await; run_maintenance_subtask( metrics_state, - "instance_bridge_out_monitor", - instance_bridge_out_monitor(local_db), + "swap_escrow_timeout_monitor", + swap_escrow_timeout_monitor(local_db), ) .await; run_maintenance_subtask( @@ -496,6 +496,7 @@ pub fn get_goat_message_content_type(content: &GOATMessageContent) -> MessageTyp GOATMessageContent::GenCircuits(_) => MessageType::GenCircuits, GOATMessageContent::CutCircuits(_) => MessageType::CutCircuits, GOATMessageContent::SolderingProofReady(_) => MessageType::SolderingProof, + GOATMessageContent::GraphSetupAck(_) => MessageType::None, GOATMessageContent::VerifierGraphParamsEndorsement(_) => { MessageType::VerifierGraphParamsEndorsement } diff --git a/node/src/utils.rs b/node/src/utils.rs index f9f5868a3..72d4be0d8 100644 --- a/node/src/utils.rs +++ b/node/src/utils.rs @@ -71,7 +71,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use store::localdb::{GraphQuery, GraphRuntimeUpdate, InstanceQuery, LocalDB, StorageProcessor}; +use store::localdb::{GraphQuery, GraphRuntimeUpdate, LocalDB, StorageProcessor}; use crate::env; use crate::rpc_service::routes::v1::{ @@ -114,7 +114,6 @@ use zkm_verifier::{ }; pub const SELF_SENDER: &str = "self"; -const BRIDGE_OUT_INSTANCE_ID_PREFIX: [u8; 4] = *b"BOID"; const BABE_SETUP_STATE_ORPHAN_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); pub(crate) fn load_committee_instance_keypair( @@ -132,26 +131,6 @@ pub(crate) fn load_committee_instance_keypair( }) } -/// Derive the shared bridge-out instance ID from its escrow hash. -/// -/// Both the RPC tag endpoint and the chain-event watcher must use this ID so -/// their concurrent create attempts collide on the primary key instead of -/// creating two rows for one escrow. -pub(crate) fn bridge_out_instance_id_from_escrow_hash(escrow_hash: &str) -> Uuid { - let normalized_escrow_hash = escrow_hash - .strip_prefix("0x") - .or_else(|| escrow_hash.strip_prefix("0X")) - .unwrap_or(escrow_hash); - let mut hasher = Sha256::new(); - hasher.update(b"bridge-out:"); - hasher.update(normalized_escrow_hash.to_ascii_lowercase().as_bytes()); - let digest = hasher.finalize(); - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(&digest[..16]); - bytes[..4].copy_from_slice(&BRIDGE_OUT_INSTANCE_ID_PREFIX); - Uuid::from_bytes(bytes) -} - pub(crate) const BRIDGE_OUT_GLOBAL_STATS_ID: i64 = 1; pub type VerifyingKey = ark_groth16::VerifyingKey; @@ -469,29 +448,38 @@ pub async fn validate_init_graph_base( graph.parameters.pubin_disprove_constant, )?; - // 5) Operator stake sanity: verify operator is registered and has enough locked stake - let op_pk_bytes = graph.parameters.operator_pubkey.to_bytes(); - let xonly: [u8; 32] = op_pk_bytes[1..33] - .try_into() - .map_err(|_| SpecialError::InvalidGraph("invalid operator pubkey".to_string()))?; - let operator_addr = goat_client.stake_mana_pubkey_to_address(&xonly).await.map_err(|e| { - SpecialError::InvalidGraph(format!("failed to query operator address: {e}")) - })?; - if operator_addr == [0u8; 20] { - bail!(SpecialError::InvalidGraph("operator not registered".to_string())); - } - let min_stake_amount = goat_client.gateway_get_min_stake_amount().await.map_err(|e| { - SpecialError::InvalidGraph(format!("failed to query min stake amount: {e}")) - })?; - let locked_stake = goat_client.stake_mana_lock_stake_of(&operator_addr).await.map_err(|e| { - SpecialError::InvalidGraph(format!("failed to query operator locked stake: {e}")) - })?; + // 5) Operator stake sanity: verify operator is registered and has enough locked stake. + validate_operator_stake(goat_client, &graph.parameters.operator_pubkey) + .await + .map_err(|error| SpecialError::InvalidGraph(error.to_string()))?; + + Ok(()) +} + +/// Verify the stake conditions required for an operator to create a graph. +/// This mirrors the Gateway graph-posting requirement and is shared by graph +/// validation and the early InitGraph admission check. +pub async fn validate_operator_stake( + goat_client: &GOATClient, + operator_pubkey: &PublicKey, +) -> Result<()> { + let operator_xonly_pubkey = XOnlyPublicKey::from(*operator_pubkey).serialize(); + let operator_addr = goat_client + .stake_mana_pubkey_to_address(&operator_xonly_pubkey) + .await + .context("query operator address")?; + if operator_addr == [0; 20] { + bail!("operator not registered"); + } + let min_stake_amount = + goat_client.gateway_get_min_stake_amount().await.context("query minimum operator stake")?; + let locked_stake = goat_client + .stake_mana_lock_stake_of(&operator_addr) + .await + .context("query operator locked stake")?; if locked_stake < min_stake_amount { - bail!(SpecialError::InvalidGraph(format!( - "insufficient operator stake: locked={locked_stake}, min={min_stake_amount}" - ))); + bail!("insufficient operator stake: locked={locked_stake}, min={min_stake_amount}"); } - Ok(()) } @@ -4330,7 +4318,6 @@ pub async fn generate_instance( Ok(Instance { instance_id: params.instance_id, - is_bridge_in: true, network: get_network().to_string(), from_addr, to_addr: EvmAddress::from(¶ms.user_info.depositor_evm_address).to_string(), @@ -4350,10 +4337,7 @@ pub async fn generate_instance( pegin_data_tx_hash: "".to_string(), btc_height: 0, parameters: None, - escrow_hash: None, - bridge_out_lock_time: 0, post_pegin_txhash: None, - bridge_out_amount: "0".to_string(), status_updated_at: params.pegin_timestamp, created_at: current_time, updated_at: current_time, @@ -5757,21 +5741,6 @@ pub async fn check_bridge_in_uxto_available_or_self_spent( Ok(true) } -pub(super) async fn find_instances_by_escrow_hash<'a>( - storage_processor: &mut StorageProcessor<'a>, - escrow_hash: &str, -) -> anyhow::Result> { - let (instances, size) = storage_processor - .find_instances( - InstanceQuery::default() - .with_is_bridge_in(false) - .with_escrow_hash(escrow_hash.to_string()) - .with_order("escrow_hash, created_at ASC".to_string()), - ) - .await?; - if size > 0 { Ok(Some(instances[0].clone())) } else { Ok(None) } -} - /// Computes the graph constant committed by the Operator guest and Connector D. pub fn get_guest_constant_value( graph_id: Uuid, @@ -5902,7 +5871,7 @@ pub(crate) async fn pending_graph_belongs_to_operator( pub(crate) async fn save_soldering_proof_payload( instance_id: Uuid, graph_id: Uuid, - verifier_index: usize, + candidate_index: usize, opened: &[(usize, u64)], finalized: &[FinalizedInstanceData], soldering: &SolderingData, @@ -5917,7 +5886,7 @@ pub(crate) async fn save_soldering_proof_payload( &store_base_path, instance_id, graph_id, - verifier_index, + candidate_index, &payload_hash, )?; let store_mode = if is_soldering_proof_s3_path(&store_base_path) { "s3" } else { "local" }; @@ -5943,7 +5912,7 @@ pub(crate) async fn save_soldering_proof_payload( payload_path = %payload_path, "saved compact soldering proof to payload store" ); - Ok(SolderingProofReady { instance_id, graph_id, verifier_index, payload_hash, total_len }) + Ok(SolderingProofReady { instance_id, graph_id, candidate_index, payload_hash, total_len }) } fn load_babe_setup_state_from_path(path: &Path) -> Result> { @@ -6107,7 +6076,7 @@ pub(crate) async fn cleanup_babe_setup_states( payload_store, instance_id, graph_id, - ready.verifier_index, + ready.candidate_index, &ready.payload_hash, )?; delete_soldering_proof_store_payload(&payload_path).await?; @@ -6129,6 +6098,10 @@ pub struct BabeSetupState { #[derive(Clone, Serialize, Deserialize)] pub struct VerifierBabeSetupState { pub verifier_pubkey: PublicKey, + /// Authenticated operator identity that initiated this setup. + pub operator_pubkey: PublicKey, + /// Signed operator P2P identity used for directed setup replies and ACKs. + pub operator_peer_id: Vec, pub setup_package: CACSetupPackage, pub private_state: BabeVerifierPrivateState, pub finalized_indices: Vec, @@ -6140,7 +6113,8 @@ pub struct OperatorVerifierCandidate { pub verifier_peer_id: Vec, pub verifier_pubkey: PublicKey, pub setup_package: CACSetupPackage, - pub verifier_index: Option, + /// Immutable slot within the candidate pool and proof payload store. + pub candidate_index: Option, pub selected_circuit_indexes: Vec, pub gc_data: Option, pub soldering_proof_ready: Option, @@ -6148,9 +6122,17 @@ pub struct OperatorVerifierCandidate { #[derive(Clone, Serialize, Deserialize)] pub struct OperatorBabeSetupState { - pub frozen_verifier_pubkeys: Option>, + /// Candidate pool closed before CutCircuits. This is not the final graph + /// verifier set; `selected_verifier_pubkeys` is set after proof validation. + pub candidate_verifier_pubkeys: Option>, pub candidates: Vec, #[serde(default)] + pub candidate_collection_started_at: Option, + #[serde(default)] + pub proof_collection_started_at: Option, + #[serde(default)] + pub selected_verifier_pubkeys: Option>, + #[serde(default)] pub asserted_operator_proof: Option>, }