From e75b50f93e0d73336be2e9cb6504553e8da6aea5 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 2 Mar 2026 10:10:15 +0100 Subject: [PATCH 01/18] Add HOT bridge counterparty spike and relayer transport wiring --- Cargo.lock | 7 + Cargo.toml | 1 + contract/vault-counterparty/.env.example | 19 + contract/vault-counterparty/Cargo.toml | 18 + contract/vault-counterparty/README.md | 37 ++ contract/vault-counterparty/justfile | 145 ++++++ contract/vault-counterparty/src/lib.rs | 376 ++++++++++++++ plans/CROSS_CHAIN_BRIDGE.md | 52 ++ service/funding-bridge/.env.example | 14 + service/funding-bridge/README.md | 67 +++ service/funding-bridge/src/app.rs | 92 ++++ .../funding-bridge/src/bridge_transport.rs | 157 ++++++ service/funding-bridge/src/hot_relayer.rs | 463 ++++++++++++++++++ service/funding-bridge/src/lib.rs | 2 + service/funding-bridge/src/routes/health.rs | 2 + service/funding-bridge/src/routes/mod.rs | 9 + service/funding-bridge/src/routes/relay.rs | 234 +++++++++ .../funding-bridge/src/routes/test_utils.rs | 2 + service/funding-bridge/src/routes/tokens.rs | 2 + service/funding-bridge/src/routes/withdraw.rs | 2 + service/funding-bridge/tests/tests.rs | 2 + 21 files changed, 1703 insertions(+) create mode 100644 contract/vault-counterparty/.env.example create mode 100644 contract/vault-counterparty/Cargo.toml create mode 100644 contract/vault-counterparty/README.md create mode 100644 contract/vault-counterparty/justfile create mode 100644 contract/vault-counterparty/src/lib.rs create mode 100644 plans/CROSS_CHAIN_BRIDGE.md create mode 100644 service/funding-bridge/src/bridge_transport.rs create mode 100644 service/funding-bridge/src/hot_relayer.rs create mode 100644 service/funding-bridge/src/routes/relay.rs diff --git a/Cargo.lock b/Cargo.lock index df92448da..ab6bb3bd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13237,6 +13237,13 @@ dependencies = [ "tokio", ] +[[package]] +name = "templar-vault-counterparty-contract" +version = "0.1.0" +dependencies = [ + "near-sdk", +] + [[package]] name = "templar-vault-kernel" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 185fcb6d9..fcd69e2be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "contract/proxy-oracle/soroban/common", "contract/proxy-oracle/soroban/integration-tests", "contract/proxy-oracle/soroban/sep40-adapter-contract", + "contract/vault-counterparty", "contract/redstone-adapter", "contract/registry", "contract/vault/macros", diff --git a/contract/vault-counterparty/.env.example b/contract/vault-counterparty/.env.example new file mode 100644 index 000000000..7c13ead93 --- /dev/null +++ b/contract/vault-counterparty/.env.example @@ -0,0 +1,19 @@ +# Required for all mainnet counterparty flows +COUNTERPARTY_ACCOUNT=your-counterparty.near +OWNER_ACCOUNT=your-owner.near +CURATOR_ACCOUNT=your-curator.near +NEAR_MARKET=your-market.near +STELLAR_RECEIVER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +OMNI_TOKEN_ID=1100_CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# Required for synthetic funding flow (`just fund` / `just smoke-synthetic`) +FUNDER_ACCOUNT=your-funder.near + +# Required to run withdraw recipes (explicit safety acknowledgement) +WITHDRAW_LIQUIDITY_ACK=YES + +# Optional overrides (defaults shown) +NETWORK_ID=mainnet +OMNI_CONTRACT=v2_1.omni.hot.tg +STORAGE_DEPOSIT_NEAR=0.25 +CALL_GAS=100000000000000 diff --git a/contract/vault-counterparty/Cargo.toml b/contract/vault-counterparty/Cargo.toml new file mode 100644 index 000000000..52a5c26e3 --- /dev/null +++ b/contract/vault-counterparty/Cargo.toml @@ -0,0 +1,18 @@ +[package] +edition.workspace = true +license.workspace = true +name = "templar-vault-counterparty-contract" +repository.workspace = true +version = "0.1.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +near-sdk.workspace = true + +[dev-dependencies] +near-sdk = { workspace = true, features = ["unit-testing"] } + +[lints] +workspace = true diff --git a/contract/vault-counterparty/README.md b/contract/vault-counterparty/README.md new file mode 100644 index 000000000..641d5d1c2 --- /dev/null +++ b/contract/vault-counterparty/README.md @@ -0,0 +1,37 @@ +# Vault Counterparty Spike (NEAR) + +This crate is a spike for the HOT Bridge counterparty described in the Stellar <-> NEAR design note. + +## What this verifies + +- Curator-only methods build the expected outbound calls to the configured `omni_contract`: + - `mt_transfer(receiver_id, token_id, amount)` + - `withdraw(token_id, receiver_id, amount)` +- Each outbound call attaches exactly `1 yoctoNEAR`. +- `mt_transfer` always uses the preconfigured `near_market` receiver from init. +- `withdraw_to_stellar` always uses the contract's configured `stellar_receiver`. +- `token_id` is immutable (`omni_token_id`) from init; runtime methods do not accept token overrides. +- omni contract is configurable at init (`omni_contract`) to allow bridge transport swaps. +- No runtime callback `msg` input is exposed in this spike (no arbitrary action payloads). +- Security guards in the spike contract: + - Reject `amount == 0` + - Reject empty `token_id` at initialization + - Cap `token_id` and `stellar_receiver` length + - Two-step owner transfer (`propose_owner` -> `accept_owner`) + +The tests inspect emitted mock receipts directly, so method names, JSON arg shapes, gas, and deposit are all checked. + +## Soroban adapter compatibility + +`stellar_receiver` is a plain string provided at init. For this spike, that lets you choose either: + +- A real Soroban adapter address (for example from `upstream/feat/soroban-curator-kernel`) +- A mock placeholder receiver during local testing + +`near_market` and `omni_token_id` are set at init and immutable in this spike, so curator cannot route to arbitrary receivers or switch bridged assets at call time. `omni_contract` is also set at init so the same contract can target a non-HOT multitoken contract in the future. + +## Run + +```bash +cargo test -p templar-vault-counterparty-contract +``` diff --git a/contract/vault-counterparty/justfile b/contract/vault-counterparty/justfile new file mode 100644 index 000000000..a346db1cc --- /dev/null +++ b/contract/vault-counterparty/justfile @@ -0,0 +1,145 @@ +set shell := ["bash", "-euo", "pipefail", "-c"] +set dotenv-load := true + +NETWORK_ID := env_var_or_default("NETWORK_ID", "mainnet") +OMNI_CONTRACT := env_var_or_default("OMNI_CONTRACT", "v2_1.omni.hot.tg") +STORAGE_DEPOSIT_NEAR := env_var_or_default("STORAGE_DEPOSIT_NEAR", "0.25") +CALL_GAS := env_var_or_default("CALL_GAS", "100000000000000") +WASM_PATH := "../../target/wasm32-unknown-unknown/release/templar_vault_counterparty_contract.wasm" + +_default: + @just --list + +# Print required env vars and available tasks. +help: + @echo "Required env vars for e2e:"; + @echo " COUNTERPARTY_ACCOUNT"; + @echo " OWNER_ACCOUNT"; + @echo " CURATOR_ACCOUNT"; + @echo " NEAR_MARKET"; + @echo " STELLAR_RECEIVER"; + @echo " OMNI_TOKEN_ID"; + @echo " FUNDER_ACCOUNT (for fund/smoke)"; + @echo " WITHDRAW_LIQUIDITY_ACK=YES (required for withdraw)"; + @echo "Optional:"; + @echo " NETWORK_ID (default: mainnet)"; + @echo " OMNI_CONTRACT (default: v2_1.omni.hot.tg)"; + @echo " STORAGE_DEPOSIT_NEAR (default: 0.25)"; + @echo " CALL_GAS (default: 100000000000000)"; + @echo; + @echo "Mainnet flow:"; + @echo " 1) just bootstrap"; + @echo " 2) just bridge-preflight"; + @echo " 3) perform Stellar->HOT locker deposit to mint omni asset"; + @echo " 4) just smoke-bridge"; + @echo; + @just --list + +check-tools: + command -v cargo >/dev/null + command -v near >/dev/null + +check-env: + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + : "${OWNER_ACCOUNT:?set OWNER_ACCOUNT}" + : "${CURATOR_ACCOUNT:?set CURATOR_ACCOUNT}" + : "${NEAR_MARKET:?set NEAR_MARKET}" + : "${STELLAR_RECEIVER:?set STELLAR_RECEIVER}" + : "${OMNI_TOKEN_ID:?set OMNI_TOKEN_ID}" + +build: check-tools + cargo build -p templar-vault-counterparty-contract --target wasm32-unknown-unknown --release + ls -lh "{{WASM_PATH}}" + +deploy: check-tools + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + near deploy --accountId "$COUNTERPARTY_ACCOUNT" --wasmFile "{{WASM_PATH}}" --networkId "{{NETWORK_ID}}" + +init: check-env + ARGS='{"stellar_receiver":"'"$STELLAR_RECEIVER"'","near_market":"'"$NEAR_MARKET"'","omni_token_id":"'"$OMNI_TOKEN_ID"'","omni_contract":"'"{{OMNI_CONTRACT}}"'","curator":"'"$CURATOR_ACCOUNT"'","owner":"'"$OWNER_ACCOUNT"'"}' + near call "$COUNTERPARTY_ACCOUNT" new "$ARGS" --accountId "$OWNER_ACCOUNT" --networkId "{{NETWORK_ID}}" + +config: + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + near view "$COUNTERPARTY_ACCOUNT" get_config '{}' --networkId "{{NETWORK_ID}}" + +storage-counterparty: + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + : "${OWNER_ACCOUNT:?set OWNER_ACCOUNT}" + near call "{{OMNI_CONTRACT}}" storage_deposit '{"account_id":"'"$COUNTERPARTY_ACCOUNT"'"}' --deposit "{{STORAGE_DEPOSIT_NEAR}}" --accountId "$OWNER_ACCOUNT" --networkId "{{NETWORK_ID}}" + +storage-market: + : "${NEAR_MARKET:?set NEAR_MARKET}" + : "${OWNER_ACCOUNT:?set OWNER_ACCOUNT}" + near call "{{OMNI_CONTRACT}}" storage_deposit '{"account_id":"'"$NEAR_MARKET"'"}' --deposit "{{STORAGE_DEPOSIT_NEAR}}" --accountId "$OWNER_ACCOUNT" --networkId "{{NETWORK_ID}}" + +storage-all: storage-counterparty storage-market + +bootstrap: build deploy init config storage-all + +bridge-preflight: check-env + @echo "Bridge preflight (mainnet):" + @echo " - Deposit underlying asset into HOT Stellar locker (via your Stellar adapter flow)." + @echo " - Ensure deposit receiver on Stellar side is your configured NEAR counterparty:" + @echo " $COUNTERPARTY_ACCOUNT" + @echo " - Wait for relayer completion so omni asset appears on NEAR." + @echo " - Verify counterparty omni balance is >= planned withdraw amount:" + @echo " just balance $COUNTERPARTY_ACCOUNT" + @echo " - Then set:" + @echo " export WITHDRAW_LIQUIDITY_ACK=YES" + +fund amount='1000': + : "${FUNDER_ACCOUNT:?set FUNDER_ACCOUNT}" + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + : "${OMNI_TOKEN_ID:?set OMNI_TOKEN_ID}" + ARGS='{"receiver_id":"'"$COUNTERPARTY_ACCOUNT"'","token_id":"'"$OMNI_TOKEN_ID"'","amount":"{{amount}}"}' + near call "{{OMNI_CONTRACT}}" mt_transfer "$ARGS" --depositYocto 1 --accountId "$FUNDER_ACCOUNT" --networkId "{{NETWORK_ID}}" --gas "{{CALL_GAS}}" + +forward amount='100': + : "${CURATOR_ACCOUNT:?set CURATOR_ACCOUNT}" + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + near call "$COUNTERPARTY_ACCOUNT" forward_to_market '{"amount":"{{amount}}"}' --accountId "$CURATOR_ACCOUNT" --networkId "{{NETWORK_ID}}" --gas "{{CALL_GAS}}" + +withdraw amount='50': + : "${CURATOR_ACCOUNT:?set CURATOR_ACCOUNT}" + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + : "${WITHDRAW_LIQUIDITY_ACK:?set WITHDRAW_LIQUIDITY_ACK=YES after confirming Stellar locker backing liquidity}" + [ "$WITHDRAW_LIQUIDITY_ACK" = "YES" ] + near call "$COUNTERPARTY_ACCOUNT" withdraw_to_stellar '{"amount":"{{amount}}"}' --accountId "$CURATOR_ACCOUNT" --networkId "{{NETWORK_ID}}" --gas "{{CALL_GAS}}" + +balance account='': + : "${OMNI_TOKEN_ID:?set OMNI_TOKEN_ID}" + ACCOUNT="{{account}}"; if [ -z "$ACCOUNT" ]; then ACCOUNT="$COUNTERPARTY_ACCOUNT"; fi + ARGS='{"account_id":"'"$ACCOUNT"'","token_id":"'"$OMNI_TOKEN_ID"'"}' + near view "{{OMNI_CONTRACT}}" mt_balance_of "$ARGS" --networkId "{{NETWORK_ID}}" + +balances: + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + : "${NEAR_MARKET:?set NEAR_MARKET}" + @echo "counterparty balance:"; + just balance "$COUNTERPARTY_ACCOUNT" + @echo "market balance:"; + just balance "$NEAR_MARKET" + +# Fast synthetic smoke path: funds counterparty by direct NEAR mt_transfer. +smoke-synthetic fund_amount='1000' forward_amount='100' withdraw_amount='50': check-env + : "${FUNDER_ACCOUNT:?set FUNDER_ACCOUNT}" + just bootstrap + just fund "{{fund_amount}}" + just balances + just forward "{{forward_amount}}" + just balances + just withdraw "{{withdraw_amount}}" + just balances + +# Real bridge smoke path: assumes Stellar->HOT locker deposit already happened. +smoke-bridge forward_amount='100' withdraw_amount='50': check-env + just bridge-preflight + just balances + just forward "{{forward_amount}}" + just balances + just withdraw "{{withdraw_amount}}" + just balances + +smoke forward_amount='100' withdraw_amount='50': + just smoke-bridge forward_amount="{{forward_amount}}" withdraw_amount="{{withdraw_amount}}" diff --git a/contract/vault-counterparty/src/lib.rs b/contract/vault-counterparty/src/lib.rs new file mode 100644 index 000000000..5f429b943 --- /dev/null +++ b/contract/vault-counterparty/src/lib.rs @@ -0,0 +1,376 @@ +#![allow(clippy::needless_pass_by_value)] + +use near_sdk::{ + env, + json_types::U128, + near, require, + serde_json::{self, json}, + AccountId, Gas, NearToken, PanicOnDefault, Promise, +}; + +const ONE_YOCTO: NearToken = NearToken::from_yoctonear(1); +const GAS_MT_TRANSFER: Gas = Gas::from_tgas(50); +const GAS_WITHDRAW: Gas = Gas::from_tgas(80); +const MAX_STELLAR_RECEIVER_LEN: usize = 256; +const MAX_TOKEN_ID_LEN: usize = 256; + +#[derive(Debug, Clone)] +#[near(serializers = [json])] +pub struct Config { + pub stellar_receiver: String, + pub near_market: AccountId, + pub omni_token_id: String, + pub curator: AccountId, + pub owner: AccountId, + pub pending_owner: Option, + pub omni_contract: AccountId, +} + +#[derive(PanicOnDefault)] +#[near(contract_state)] +pub struct Contract { + stellar_receiver: String, + near_market: AccountId, + omni_token_id: String, + curator: AccountId, + owner: AccountId, + pending_owner: Option, + omni_contract: AccountId, +} + +#[near] +impl Contract { + #[init] + pub fn new( + stellar_receiver: String, + near_market: AccountId, + omni_token_id: String, + omni_contract: AccountId, + curator: AccountId, + owner: AccountId, + ) -> Self { + Self::assert_receiver(&stellar_receiver); + Self::assert_token_id(&omni_token_id); + + Self { + stellar_receiver, + near_market, + omni_token_id, + curator, + owner, + pending_owner: None, + omni_contract, + } + } + + pub fn forward_to_market(&self, amount: U128) -> Promise { + self.assert_curator(); + Self::assert_amount(amount); + + self.call_omni( + "mt_transfer", + json!({ + "receiver_id": self.near_market, + "token_id": self.omni_token_id, + "amount": amount, + }), + GAS_MT_TRANSFER, + ) + } + + pub fn withdraw_to_stellar(&self, amount: U128) -> Promise { + self.assert_curator(); + Self::assert_amount(amount); + + self.call_omni( + "withdraw", + json!({ + "token_id": self.omni_token_id, + "receiver_id": self.stellar_receiver, + "amount": amount, + }), + GAS_WITHDRAW, + ) + } + + pub fn set_curator(&mut self, curator: AccountId) { + self.assert_owner(); + self.curator = curator; + env::log_str("curator_updated"); + } + + pub fn propose_owner(&mut self, pending_owner: AccountId) { + self.assert_owner(); + require!(pending_owner != self.owner, "new owner must differ"); + self.pending_owner = Some(pending_owner); + env::log_str("owner_proposed"); + } + + pub fn accept_owner(&mut self) { + let predecessor = env::predecessor_account_id(); + require!( + self.pending_owner + .as_ref() + .is_some_and(|pending| pending == &predecessor), + "Only pending owner can accept ownership" + ); + self.owner = predecessor; + self.pending_owner = None; + env::log_str("owner_accepted"); + } + + pub fn get_config(&self) -> Config { + Config { + stellar_receiver: self.stellar_receiver.clone(), + near_market: self.near_market.clone(), + omni_token_id: self.omni_token_id.clone(), + curator: self.curator.clone(), + owner: self.owner.clone(), + pending_owner: self.pending_owner.clone(), + omni_contract: self.omni_contract.clone(), + } + } + + fn call_omni(&self, method_name: &str, args: serde_json::Value, gas: Gas) -> Promise { + Promise::new(self.omni_contract.clone()).function_call( + method_name.to_string(), + serde_json::to_vec(&args) + .unwrap_or_else(|_| env::panic_str("failed to serialize omni call args")), + ONE_YOCTO, + gas, + ) + } + + fn assert_curator(&self) { + require!( + env::predecessor_account_id() == self.curator, + "Only curator can call this method" + ); + } + + fn assert_owner(&self) { + require!( + env::predecessor_account_id() == self.owner, + "Only owner can call this method" + ); + } + + fn assert_amount(amount: U128) { + require!(amount.0 > 0, "amount must be > 0"); + } + + fn assert_token_id(token_id: &str) { + require!(!token_id.trim().is_empty(), "token_id cannot be empty"); + require!( + token_id.len() <= MAX_TOKEN_ID_LEN, + format!("token_id too long, max {}", MAX_TOKEN_ID_LEN) + ); + } + + fn assert_receiver(receiver: &str) { + require!( + !receiver.trim().is_empty(), + "stellar receiver cannot be empty" + ); + require!( + receiver.len() <= MAX_STELLAR_RECEIVER_LEN, + format!( + "stellar receiver too long, max {}", + MAX_STELLAR_RECEIVER_LEN + ) + ); + } +} + +#[cfg(test)] +mod tests { + use near_sdk::{ + mock::MockAction, + serde_json::Value, + test_utils::{get_created_receipts, VMContextBuilder}, + testing_env, AccountId, + }; + + use super::*; + + fn account(account_id: &str) -> AccountId { + account_id + .parse() + .unwrap_or_else(|_| panic!("invalid account id: {account_id}")) + } + + fn context(predecessor: &AccountId) { + let mut builder = VMContextBuilder::new(); + builder.current_account_id(account("counterparty.near")); + builder.predecessor_account_id(predecessor.clone()); + builder.signer_account_id(predecessor.clone()); + testing_env!(builder.build()); + } + + fn test_contract() -> Contract { + Contract::new( + "GCYV3WBXWJY4UVQZ6X3I6LBKAKP4YB6ESQOKJP4MZ2S2BFOFGB2P4D7F".to_string(), + account("templar-market.near"), + "1100_stellar_usdc".to_string(), + account("v2_1.omni.hot.tg"), + account("curator.near"), + account("owner.near"), + ) + } + + fn first_function_call() -> (AccountId, String, Value, NearToken, Gas) { + let receipts = get_created_receipts(); + assert_eq!(receipts.len(), 1, "expected exactly one outgoing receipt"); + + let receipt = &receipts[0]; + assert_eq!(receipt.actions.len(), 1, "expected exactly one action"); + + let MockAction::FunctionCallWeight { + method_name, + args, + attached_deposit, + prepaid_gas, + .. + } = &receipt.actions[0] + else { + panic!("expected FunctionCallWeight action") + }; + + let method_name = String::from_utf8(method_name.clone()).unwrap_or_else(|e| panic!("{e}")); + let args: Value = serde_json::from_slice(args).unwrap_or_else(|e| panic!("{e}")); + + ( + receipt.receiver_id.clone(), + method_name, + args, + *attached_deposit, + *prepaid_gas, + ) + } + + #[test] + fn forward_to_market_builds_expected_mt_transfer_call() { + let contract = test_contract(); + context(&account("curator.near")); + + contract.forward_to_market(U128(42)); + + let (receiver, method, args, attached_deposit, prepaid_gas) = first_function_call(); + assert_eq!(receiver, account("v2_1.omni.hot.tg")); + assert_eq!(method, "mt_transfer"); + assert_eq!(attached_deposit, ONE_YOCTO); + assert_eq!(prepaid_gas, GAS_MT_TRANSFER); + assert_eq!(args["receiver_id"], "templar-market.near"); + assert_eq!(args["token_id"], "1100_stellar_usdc"); + assert_eq!(args["amount"], "42"); + } + + #[test] + fn withdraw_to_stellar_uses_hardcoded_receiver_and_token() { + let contract = test_contract(); + context(&account("curator.near")); + + contract.withdraw_to_stellar(U128(999)); + + let (receiver, method, args, attached_deposit, prepaid_gas) = first_function_call(); + assert_eq!(receiver, account("v2_1.omni.hot.tg")); + assert_eq!(method, "withdraw"); + assert_eq!(attached_deposit, ONE_YOCTO); + assert_eq!(prepaid_gas, GAS_WITHDRAW); + assert_eq!( + args["receiver_id"], + "GCYV3WBXWJY4UVQZ6X3I6LBKAKP4YB6ESQOKJP4MZ2S2BFOFGB2P4D7F" + ); + assert_eq!(args["token_id"], "1100_stellar_usdc"); + assert_eq!(args["amount"], "999"); + } + + #[test] + #[should_panic(expected = "Only curator can call this method")] + fn non_curator_cannot_withdraw_to_stellar() { + let contract = test_contract(); + context(&account("not-curator.near")); + + contract.withdraw_to_stellar(U128(1)); + } + + #[test] + fn owner_can_rotate_curator() { + let mut contract = test_contract(); + + context(&account("owner.near")); + contract.set_curator(account("new-curator.near")); + + context(&account("new-curator.near")); + contract.withdraw_to_stellar(U128(5)); + + let (_, method, _, _, _) = first_function_call(); + assert_eq!(method, "withdraw"); + } + + #[test] + fn token_id_is_fixed_from_configuration() { + let contract = test_contract(); + context(&account("curator.near")); + contract.forward_to_market(U128(7)); + let (_, _, args, _, _) = first_function_call(); + assert_eq!(args["token_id"], "1100_stellar_usdc"); + } + + #[test] + #[should_panic(expected = "amount must be > 0")] + fn rejects_zero_amount() { + let contract = test_contract(); + context(&account("curator.near")); + contract.withdraw_to_stellar(U128(0)); + } + + #[test] + #[should_panic(expected = "Only owner can call this method")] + fn non_owner_cannot_rotate_curator() { + let mut contract = test_contract(); + context(&account("curator.near")); + contract.set_curator(account("attacker.near")); + } + + #[test] + fn ownership_transfer_is_two_step() { + let mut contract = test_contract(); + context(&account("owner.near")); + contract.propose_owner(account("new-owner.near")); + + context(&account("new-owner.near")); + contract.accept_owner(); + + let config = contract.get_config(); + assert_eq!(config.owner, account("new-owner.near")); + assert_eq!(config.pending_owner, None); + assert_eq!(config.near_market, account("templar-market.near")); + assert_eq!(config.omni_token_id, "1100_stellar_usdc"); + assert_eq!(config.omni_contract, account("v2_1.omni.hot.tg")); + } + + #[test] + #[should_panic(expected = "Only pending owner can accept ownership")] + fn non_pending_owner_cannot_accept_ownership() { + let mut contract = test_contract(); + context(&account("owner.near")); + contract.propose_owner(account("new-owner.near")); + + context(&account("someone-else.near")); + contract.accept_owner(); + } + + #[test] + #[should_panic(expected = "token_id cannot be empty")] + fn init_rejects_empty_token_id() { + let _ = Contract::new( + "GCYV3WBXWJY4UVQZ6X3I6LBKAKP4YB6ESQOKJP4MZ2S2BFOFGB2P4D7F".to_string(), + account("templar-market.near"), + "".to_string(), + account("v2_1.omni.hot.tg"), + account("curator.near"), + account("owner.near"), + ); + } +} diff --git a/plans/CROSS_CHAIN_BRIDGE.md b/plans/CROSS_CHAIN_BRIDGE.md new file mode 100644 index 000000000..0d0221931 --- /dev/null +++ b/plans/CROSS_CHAIN_BRIDGE.md @@ -0,0 +1,52 @@ +# Cross-Chain Bridge: Stellar <-> NEAR (HOT First, Swappable Transport) + +## Decision + +Ship with HOT Bridge now, but keep transport swappable. + +## Addendum: Transport Abstraction + +### Goals + +- Ship quickly on HOT while it is live and working. +- Avoid lock-in if HOT Stellar support degrades. +- Keep vault + market contracts stable across transport swaps. + +### Contract Boundaries + +- NEAR counterparty is a verifier and router guardrail, not a bridge implementation. +- Counterparty has immutable init config: + - `stellar_receiver` + - `near_market` + - `omni_token_id` + - `omni_contract` +- Runtime methods do not accept arbitrary receiver/token/action payload overrides. + +### Relayer Boundary + +Introduce a bridge-transport interface in the relayer service: + +- `BridgeRelayer::complete_deposit(event)` +- `BridgeRelayer::complete_withdrawal(pending)` + +`HotBridgeRelayer` is the first implementation. + +If transport changes later, implement a new relayer adapter behind the same trait and keep the loop/orchestration code stable. + +## What Changes on Transport Swap + +- Deploy new Stellar adapter for the new bridge locker/prover model. +- Deploy or point counterparty to new `omni_contract`. +- Switch relayer transport implementation. + +## What Does Not Change + +- Templar market contracts. +- Vault contract logic. +- Counterparty RBAC model and immutable routing constraints. + +## Operational Notes + +- Mainnet-only verification is acceptable with tiny amounts due to no public HOT testnet. +- Confirm MPC API access/limits with HOT team. +- Keep monitoring and fallback runbooks for relayer liveness. diff --git a/service/funding-bridge/.env.example b/service/funding-bridge/.env.example index 8c1779485..62ad37e80 100644 --- a/service/funding-bridge/.env.example +++ b/service/funding-bridge/.env.example @@ -21,6 +21,20 @@ NEAR_TREASURY_KEY=ed25519:YOUR_PRIVATE_KEY_HERE # ---------------------------------------------------------------------------- BRIDGE_API_URL=https://bridge.chaindefuser.com/rpc +# ---------------------------------------------------------------------------- +# Bridge Relayer Backend (Optional) +# ---------------------------------------------------------------------------- +# Set to "hot" to enable HOT bridge relayer completion endpoints. +# Supported values: none, hot +BRIDGE_RELAYER_BACKEND=none + +# Required when BRIDGE_RELAYER_BACKEND=hot +# HOT_MPC_API_URL=https://your-hot-mpc-api +# HOT_RELAYER_NEAR_RECEIVER=vault-counterparty.near +# HOT_RELAYER_STELLAR_RECEIVER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# Optional but recommended when relay endpoints are exposed: +# BRIDGE_RELAYER_AUTH_TOKEN=replace-with-long-random-token + # ---------------------------------------------------------------------------- # External Chain Wallets (Optional) # ---------------------------------------------------------------------------- diff --git a/service/funding-bridge/README.md b/service/funding-bridge/README.md index 77c5ec3d5..b6a7b60a9 100644 --- a/service/funding-bridge/README.md +++ b/service/funding-bridge/README.md @@ -13,6 +13,7 @@ A NEAR-centric treasury management service with cross-chain deposits and withdra - ✅ **Token Resolution** - Automatic OMFT token ID and decimal handling - ✅ **Stateless Design** - No database required, horizontally scalable - ✅ **REST API** - Simple HTTP/JSON interface +- ✅ **Bridge Relayer Backend** - Configurable transport abstraction (`none`/`hot`) - ✅ **Prometheus Metrics** - Production-grade observability - ✅ **Dry-Run Mode** - Test operations without executing transactions @@ -53,6 +54,15 @@ export NETWORK=mainnet # Optional: Bridge API (default: https://bridge.chaindefuser.com/rpc) export BRIDGE_API_URL=https://bridge.chaindefuser.com/rpc +# Optional: Bridge relayer backend (defaults to none) +export BRIDGE_RELAYER_BACKEND=none +# export BRIDGE_RELAYER_BACKEND=hot +# export HOT_MPC_API_URL=https://your-hot-mpc-api +# export HOT_RELAYER_NEAR_RECEIVER=vault-counterparty.near +# export HOT_RELAYER_STELLAR_RECEIVER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# Optional (recommended if service is network-exposed): +# export BRIDGE_RELAYER_AUTH_TOKEN=replace-with-long-random-token + # Run the service ./target/release/funding-bridge ``` @@ -272,6 +282,57 @@ curl "http://localhost:3000/tokens/lookup?asset=USDT&chain=ethereum" } ``` +#### 6. Relay Completion (Optional, backend-dependent) + +These endpoints are available only when a bridge relayer backend is configured. +For HOT, set: + +- `BRIDGE_RELAYER_BACKEND=hot` +- `HOT_MPC_API_URL` +- `HOT_RELAYER_NEAR_RECEIVER` +- `HOT_RELAYER_STELLAR_RECEIVER` + +If `BRIDGE_RELAYER_AUTH_TOKEN` is configured, include: + +- `Authorization: Bearer ` + +Complete deposit signature payload: + +```bash +curl -X POST http://localhost:3000/relay/deposit/complete \ + -H "Authorization: Bearer $BRIDGE_RELAYER_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "event": { + "chain_id": 1100, + "nonce": "123", + "sender_id": "GVAULT...", + "receiver_id": "vault-counterparty.near", + "token_id": "1100_C...", + "amount": "1000" + } + }' +``` + +Complete withdrawal execution payload: + +```bash +curl -X POST http://localhost:3000/relay/withdrawal/complete \ + -H "Authorization: Bearer $BRIDGE_RELAYER_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "pending": { + "nonce": "123", + "chain_id": 1100, + "withdraw_data": { + "receiver_id": "GADAPTER...", + "amount": "1000", + "token_id": "1100_C..." + } + } + }' +``` + ## Use Cases ### 1. Liquidation Bot Pre-funding @@ -369,6 +430,12 @@ All CLI arguments can be set via environment variables: PORT=3000 NETWORK=mainnet BRIDGE_API_URL=https://bridge.chaindefuser.com/rpc +BRIDGE_RELAYER_BACKEND=none +# BRIDGE_RELAYER_BACKEND=hot +# HOT_MPC_API_URL=https://your-hot-mpc-api +# HOT_RELAYER_NEAR_RECEIVER=vault-counterparty.near +# HOT_RELAYER_STELLAR_RECEIVER=G... +# BRIDGE_RELAYER_AUTH_TOKEN=replace-with-long-random-token DRY_RUN=false # NEAR treasury diff --git a/service/funding-bridge/src/app.rs b/service/funding-bridge/src/app.rs index 40f80faf3..5b8e4987c 100644 --- a/service/funding-bridge/src/app.rs +++ b/service/funding-bridge/src/app.rs @@ -4,8 +4,10 @@ use std::sync::Arc; use crate::{ bridge::BridgeClient, + bridge_transport::{BridgeRelayer, HotBridgeRelayer}, config::Args, external::{config::EvmChainConfig, evm::EvmChainHandler, ExternalChainRegistry}, + hot_relayer::{HotMpcApiClient, HotRelayerRouting}, tokens::TokenRegistry, treasury::NearHandler, VERSION, @@ -26,6 +28,12 @@ pub struct App { /// External chain registry for cross-chain deposits pub external_chains: Arc, + /// Optional bridge relayer backend used for transport-specific completion flows. + pub bridge_relayer: Option>, + + /// Optional bearer token required for relay completion endpoints. + pub bridge_relayer_auth_token: Option, + /// Configuration pub config: Arc, @@ -69,18 +77,102 @@ impl App { // Initialize external chain registry let external_chains = Self::build_external_chain_registry(args); + let bridge_relayer = Self::build_bridge_relayer(); + let bridge_relayer_auth_token = Self::build_bridge_relayer_auth_token(); Self { near_handler, bridge_client, token_registry, external_chains, + bridge_relayer, + bridge_relayer_auth_token, config: Arc::new(args.clone()), dry_run: args.dry_run, version: VERSION, } } + /// Build optional bridge relayer backend from environment variables. + /// + /// `BRIDGE_RELAYER_BACKEND` options: + /// - `none` (default) + /// - `hot` + /// + /// For `hot`, these environment variables are required: + /// - `HOT_MPC_API_URL` + /// - `HOT_RELAYER_NEAR_RECEIVER` + /// - `HOT_RELAYER_STELLAR_RECEIVER` + fn build_bridge_relayer() -> Option> { + let backend = std::env::var("BRIDGE_RELAYER_BACKEND") + .unwrap_or_else(|_| "none".to_string()) + .to_lowercase(); + + match backend.as_str() { + "" | "none" => None, + "hot" => { + let mpc_api_url = match std::env::var("HOT_MPC_API_URL") { + Ok(v) if !v.trim().is_empty() => v, + _ => { + tracing::warn!( + "BRIDGE_RELAYER_BACKEND=hot but HOT_MPC_API_URL is missing; disabling bridge relayer backend" + ); + return None; + } + }; + let near_receiver = match std::env::var("HOT_RELAYER_NEAR_RECEIVER") { + Ok(v) if !v.trim().is_empty() => v, + _ => { + tracing::warn!( + "BRIDGE_RELAYER_BACKEND=hot but HOT_RELAYER_NEAR_RECEIVER is missing; disabling bridge relayer backend" + ); + return None; + } + }; + let stellar_receiver = match std::env::var("HOT_RELAYER_STELLAR_RECEIVER") { + Ok(v) if !v.trim().is_empty() => v, + _ => { + tracing::warn!( + "BRIDGE_RELAYER_BACKEND=hot but HOT_RELAYER_STELLAR_RECEIVER is missing; disabling bridge relayer backend" + ); + return None; + } + }; + + let routing = HotRelayerRouting { + near_receiver, + stellar_receiver, + }; + let signer = HotMpcApiClient::new(mpc_api_url); + let relayer = HotBridgeRelayer::new(routing, signer); + + tracing::info!(backend = "hot", "Configured bridge relayer backend"); + Some(Arc::new(relayer)) + } + other => { + tracing::warn!( + backend = %other, + "Unknown BRIDGE_RELAYER_BACKEND; disabling bridge relayer backend" + ); + None + } + } + } + + fn build_bridge_relayer_auth_token() -> Option { + match std::env::var("BRIDGE_RELAYER_AUTH_TOKEN") { + Ok(token) => { + let trimmed = token.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + Err(_) => None, + } + } + /// Build external chain registry from configuration fn build_external_chain_registry(args: &Args) -> Arc { let mut registry = ExternalChainRegistry::new(); diff --git a/service/funding-bridge/src/bridge_transport.rs b/service/funding-bridge/src/bridge_transport.rs new file mode 100644 index 000000000..8f55ead10 --- /dev/null +++ b/service/funding-bridge/src/bridge_transport.rs @@ -0,0 +1,157 @@ +use async_trait::async_trait; + +use crate::hot_relayer::{ + deposit_sign_request_from_event_checked, plan_stellar_withdraw_execution_checked, + DepositSignRequest, HotMpcSigner, HotRelayerError, HotRelayerRouting, PendingWithdrawal, + StellarDepositEvent, StellarWithdrawExecution, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepositCompletion { + pub sign_request: DepositSignRequest, + pub signature: String, +} + +#[async_trait] +pub trait BridgeRelayer { + async fn complete_deposit( + &self, + event: &StellarDepositEvent, + ) -> Result; + + async fn complete_withdrawal( + &self, + pending: &PendingWithdrawal, + ) -> Result; +} + +#[derive(Debug, Clone)] +pub struct HotBridgeRelayer { + routing: HotRelayerRouting, + signer: S, +} + +impl HotBridgeRelayer { + #[must_use] + pub fn new(routing: HotRelayerRouting, signer: S) -> Self { + Self { routing, signer } + } + + #[must_use] + pub fn routing(&self) -> &HotRelayerRouting { + &self.routing + } +} + +#[async_trait] +impl BridgeRelayer for HotBridgeRelayer +where + S: HotMpcSigner + Send + Sync, +{ + async fn complete_deposit( + &self, + event: &StellarDepositEvent, + ) -> Result { + let sign_request = deposit_sign_request_from_event_checked(event, &self.routing)?; + let signature = self.signer.deposit_sign(&sign_request).await?; + Ok(DepositCompletion { + sign_request, + signature, + }) + } + + async fn complete_withdrawal( + &self, + pending: &PendingWithdrawal, + ) -> Result { + plan_stellar_withdraw_execution_checked(&self.signer, pending, &self.routing).await + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + + #[derive(Default)] + struct RecordingSigner { + deposit_calls: Mutex>, + } + + #[async_trait] + impl HotMpcSigner for RecordingSigner { + async fn withdraw_sign(&self, nonce: &str) -> Result { + Ok(format!("sig-withdraw-{nonce}")) + } + + async fn deposit_sign( + &self, + request: &DepositSignRequest, + ) -> Result { + self.deposit_calls + .lock() + .unwrap_or_else(|e| panic!("{e}")) + .push(request.clone()); + Ok("sig-deposit".to_string()) + } + } + + fn routing() -> HotRelayerRouting { + HotRelayerRouting { + near_receiver: "vault-counterparty.near".to_string(), + stellar_receiver: "GADAPTERADDRESS".to_string(), + } + } + + #[tokio::test] + async fn hot_relayer_completes_deposit_through_checked_route() { + let signer = RecordingSigner::default(); + let relayer = HotBridgeRelayer::new(routing(), signer); + let event = StellarDepositEvent { + chain_id: 1100, + nonce: "55".to_string(), + sender_id: "GVAULT".to_string(), + receiver_id: "vault-counterparty.near".to_string(), + token_id: "1100_CUSDC".to_string(), + amount: "9".to_string(), + }; + + let completion = relayer + .complete_deposit(&event) + .await + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(completion.signature, "sig-deposit"); + assert_eq!( + completion.sign_request.receiver_id, + "vault-counterparty.near" + ); + } + + #[tokio::test] + async fn hot_relayer_rejects_unexpected_deposit_receiver() { + let signer = RecordingSigner::default(); + let relayer = HotBridgeRelayer::new(routing(), signer); + let event = StellarDepositEvent { + chain_id: 1100, + nonce: "55".to_string(), + sender_id: "GVAULT".to_string(), + receiver_id: "unexpected.near".to_string(), + token_id: "1100_CUSDC".to_string(), + amount: "9".to_string(), + }; + + let error = relayer + .complete_deposit(&event) + .await + .expect_err("expected receiver mismatch"); + assert!(matches!( + error, + HotRelayerError::UnexpectedReceiver { + direction: "deposit", + .. + } + )); + } +} diff --git a/service/funding-bridge/src/hot_relayer.rs b/service/funding-bridge/src/hot_relayer.rs new file mode 100644 index 000000000..8fc6d45fa --- /dev/null +++ b/service/funding-bridge/src/hot_relayer.rs @@ -0,0 +1,463 @@ +//! HOT Bridge relayer primitives used to verify receiver-safety assumptions. +//! +//! This module focuses on one invariant: +//! - For withdrawals, relayer asks MPC to sign by `nonce` only, then uses +//! receiver data already committed on NEAR (`withdraw_data.receiver_id`). +//! - For deposits, relayer signs exactly the tuple extracted from the source +//! chain event, including `receiver_id`. + +use async_trait::async_trait; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingWithdrawal { + pub nonce: String, + pub chain_id: u64, + pub withdraw_data: PendingWithdrawData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingWithdrawData { + pub receiver_id: String, + pub amount: String, + pub token_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StellarDepositEvent { + pub chain_id: u64, + pub nonce: String, + pub sender_id: String, + pub receiver_id: String, + pub token_id: String, + pub amount: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DepositSignRequest { + pub chain: u64, + pub nonce: String, + pub sender_id: String, + pub receiver_id: String, + pub token_id: String, + pub amount: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub autopilot: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StellarWithdrawExecution { + pub nonce: String, + pub token_id: String, + pub receiver: String, + pub amount: String, + pub signature: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum HotRelayerError { + #[error("http request failed: {0}")] + Http(String), + #[error("mpc api returned status {status}: {body}")] + HttpStatus { status: u16, body: String }, + #[error("failed to decode mpc response: {0}")] + Decode(String), + #[error("unexpected {direction} receiver: expected {expected}, got {actual}")] + UnexpectedReceiver { + direction: &'static str, + expected: String, + actual: String, + }, +} + +#[derive(Debug, Clone, Deserialize)] +struct SignatureResponse { + signature: String, +} + +#[async_trait] +pub trait HotMpcSigner { + async fn withdraw_sign(&self, nonce: &str) -> Result; + async fn deposit_sign(&self, request: &DepositSignRequest) -> Result; +} + +#[derive(Debug, Clone)] +pub struct HotMpcApiClient { + client: reqwest::Client, + base_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HotRelayerRouting { + pub near_receiver: String, + pub stellar_receiver: String, +} + +impl HotRelayerRouting { + pub fn validate_deposit_event( + &self, + event: &StellarDepositEvent, + ) -> Result<(), HotRelayerError> { + if event.receiver_id != self.near_receiver { + return Err(HotRelayerError::UnexpectedReceiver { + direction: "deposit", + expected: self.near_receiver.clone(), + actual: event.receiver_id.clone(), + }); + } + Ok(()) + } + + pub fn validate_pending_withdrawal( + &self, + pending: &PendingWithdrawal, + ) -> Result<(), HotRelayerError> { + if pending.withdraw_data.receiver_id != self.stellar_receiver { + return Err(HotRelayerError::UnexpectedReceiver { + direction: "withdrawal", + expected: self.stellar_receiver.clone(), + actual: pending.withdraw_data.receiver_id.clone(), + }); + } + Ok(()) + } +} + +impl HotMpcApiClient { + #[must_use] + pub fn new(base_url: String) -> Self { + Self { + client: reqwest::Client::new(), + base_url, + } + } + + fn url(&self, path: &str) -> String { + format!( + "{}/{}", + self.base_url.trim_end_matches('/'), + path.trim_start_matches('/') + ) + } + + async fn sign( + &self, + path: &str, + body: &T, + ) -> Result { + let response = self + .client + .post(self.url(path)) + .json(body) + .send() + .await + .map_err(|e| HotRelayerError::Http(e.to_string()))?; + + let status = response.status(); + let bytes = response + .bytes() + .await + .map_err(|e| HotRelayerError::Http(e.to_string()))?; + let body_text = String::from_utf8_lossy(&bytes).to_string(); + + if status != StatusCode::OK { + return Err(HotRelayerError::HttpStatus { + status: status.as_u16(), + body: body_text, + }); + } + + let parsed: SignatureResponse = + serde_json::from_slice(&bytes).map_err(|e| HotRelayerError::Decode(e.to_string()))?; + Ok(parsed.signature) + } +} + +#[async_trait] +impl HotMpcSigner for HotMpcApiClient { + async fn withdraw_sign(&self, nonce: &str) -> Result { + #[derive(Serialize)] + struct WithdrawSignRequest<'a> { + nonce: &'a str, + } + + self.sign("/withdraw/sign", &WithdrawSignRequest { nonce }) + .await + } + + async fn deposit_sign(&self, request: &DepositSignRequest) -> Result { + self.sign("/deposit/sign", request).await + } +} + +#[must_use] +fn deposit_sign_request_from_event_unchecked(event: &StellarDepositEvent) -> DepositSignRequest { + DepositSignRequest { + chain: event.chain_id, + nonce: event.nonce.clone(), + sender_id: event.sender_id.clone(), + receiver_id: event.receiver_id.clone(), + token_id: event.token_id.clone(), + amount: event.amount.clone(), + autopilot: None, + } +} + +pub fn deposit_sign_request_from_event_checked( + event: &StellarDepositEvent, + routing: &HotRelayerRouting, +) -> Result { + routing.validate_deposit_event(event)?; + Ok(deposit_sign_request_from_event_unchecked(event)) +} + +#[must_use] +pub fn build_stellar_withdraw_execution( + pending: &PendingWithdrawal, + signature: String, +) -> StellarWithdrawExecution { + StellarWithdrawExecution { + nonce: pending.nonce.clone(), + token_id: pending.withdraw_data.token_id.clone(), + receiver: pending.withdraw_data.receiver_id.clone(), + amount: pending.withdraw_data.amount.clone(), + signature, + } +} + +async fn plan_stellar_withdraw_execution_unchecked( + signer: &S, + pending: &PendingWithdrawal, +) -> Result { + let signature = signer.withdraw_sign(&pending.nonce).await?; + Ok(build_stellar_withdraw_execution(pending, signature)) +} + +pub async fn plan_stellar_withdraw_execution_checked( + signer: &S, + pending: &PendingWithdrawal, + routing: &HotRelayerRouting, +) -> Result { + routing.validate_pending_withdrawal(pending)?; + plan_stellar_withdraw_execution_unchecked(signer, pending).await +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use serde_json::json; + use wiremock::{ + matchers::{body_json, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + + #[derive(Default)] + struct RecordingSigner { + withdraw_nonces: Mutex>, + deposit_requests: Mutex>, + } + + #[async_trait] + impl HotMpcSigner for RecordingSigner { + async fn withdraw_sign(&self, nonce: &str) -> Result { + self.withdraw_nonces + .lock() + .unwrap_or_else(|e| panic!("{e}")) + .push(nonce.to_string()); + Ok("withdraw-signature".to_string()) + } + + async fn deposit_sign( + &self, + request: &DepositSignRequest, + ) -> Result { + self.deposit_requests + .lock() + .unwrap_or_else(|e| panic!("{e}")) + .push(request.clone()); + Ok("deposit-signature".to_string()) + } + } + + #[tokio::test] + async fn withdrawal_plan_uses_nonce_for_sign_and_committed_receiver_for_execution() { + let signer = RecordingSigner::default(); + let pending = PendingWithdrawal { + nonce: "991".to_string(), + chain_id: 1100, + withdraw_data: PendingWithdrawData { + receiver_id: "GADAPTERADDRESS".to_string(), + amount: "1500".to_string(), + token_id: "1100_CUSDC".to_string(), + }, + }; + + let routing = HotRelayerRouting { + near_receiver: "vault-counterparty.near".to_string(), + stellar_receiver: "GADAPTERADDRESS".to_string(), + }; + + let execution = plan_stellar_withdraw_execution_checked(&signer, &pending, &routing) + .await + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(execution.nonce, "991"); + assert_eq!(execution.signature, "withdraw-signature"); + assert_eq!(execution.receiver, "GADAPTERADDRESS"); + assert_eq!(execution.token_id, "1100_CUSDC"); + assert_eq!(execution.amount, "1500"); + assert_eq!( + signer + .withdraw_nonces + .lock() + .unwrap_or_else(|e| panic!("{e}")) + .as_slice(), + &["991".to_string()] + ); + } + + #[test] + fn deposit_sign_request_copies_receiver_from_event() { + let event = StellarDepositEvent { + chain_id: 1100, + nonce: "21".to_string(), + sender_id: "GVAULT".to_string(), + receiver_id: "vault-counterparty.near".to_string(), + token_id: "1100_CUSDC".to_string(), + amount: "42".to_string(), + }; + let routing = HotRelayerRouting { + near_receiver: "vault-counterparty.near".to_string(), + stellar_receiver: "GADAPTERADDRESS".to_string(), + }; + + let request = deposit_sign_request_from_event_checked(&event, &routing) + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(request.chain, 1100); + assert_eq!(request.nonce, "21"); + assert_eq!(request.sender_id, "GVAULT"); + assert_eq!(request.receiver_id, "vault-counterparty.near"); + assert_eq!(request.token_id, "1100_CUSDC"); + assert_eq!(request.amount, "42"); + assert_eq!(request.autopilot, None); + } + + #[test] + fn deposit_sign_request_checked_rejects_unexpected_receiver() { + let event = StellarDepositEvent { + chain_id: 1100, + nonce: "21".to_string(), + sender_id: "GVAULT".to_string(), + receiver_id: "wrong.near".to_string(), + token_id: "1100_CUSDC".to_string(), + amount: "42".to_string(), + }; + let routing = HotRelayerRouting { + near_receiver: "vault-counterparty.near".to_string(), + stellar_receiver: "GADAPTERADDRESS".to_string(), + }; + + let error = deposit_sign_request_from_event_checked(&event, &routing) + .expect_err("expected receiver mismatch"); + assert!(matches!( + error, + HotRelayerError::UnexpectedReceiver { + direction: "deposit", + .. + } + )); + } + + #[tokio::test] + async fn withdrawal_plan_checked_rejects_unexpected_receiver() { + let signer = RecordingSigner::default(); + let pending = PendingWithdrawal { + nonce: "991".to_string(), + chain_id: 1100, + withdraw_data: PendingWithdrawData { + receiver_id: "wrong-address".to_string(), + amount: "1500".to_string(), + token_id: "1100_CUSDC".to_string(), + }, + }; + let routing = HotRelayerRouting { + near_receiver: "vault-counterparty.near".to_string(), + stellar_receiver: "GADAPTERADDRESS".to_string(), + }; + + let error = plan_stellar_withdraw_execution_checked(&signer, &pending, &routing) + .await + .expect_err("expected receiver mismatch"); + assert!(matches!( + error, + HotRelayerError::UnexpectedReceiver { + direction: "withdrawal", + .. + } + )); + } + + #[tokio::test] + async fn mpc_client_posts_only_nonce_for_withdraw_sign() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/withdraw/sign")) + .and(body_json(json!({ "nonce": "4242" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "signature": "sig-1" + }))) + .mount(&server) + .await; + + let client = HotMpcApiClient::new(server.uri()); + let signature = client + .withdraw_sign("4242") + .await + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(signature, "sig-1"); + } + + #[tokio::test] + async fn mpc_client_posts_deposit_sign_tuple_including_receiver() { + let server = MockServer::start().await; + let request = DepositSignRequest { + chain: 1100, + nonce: "12".to_string(), + sender_id: "GVAULT".to_string(), + receiver_id: "vault-counterparty.near".to_string(), + token_id: "1100_CUSDC".to_string(), + amount: "77".to_string(), + autopilot: None, + }; + + Mock::given(method("POST")) + .and(path("/deposit/sign")) + .and(body_json(json!({ + "chain": 1100, + "nonce": "12", + "sender_id": "GVAULT", + "receiver_id": "vault-counterparty.near", + "token_id": "1100_CUSDC", + "amount": "77" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "signature": "sig-2" + }))) + .mount(&server) + .await; + + let client = HotMpcApiClient::new(server.uri()); + let signature = client + .deposit_sign(&request) + .await + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(signature, "sig-2"); + } +} diff --git a/service/funding-bridge/src/lib.rs b/service/funding-bridge/src/lib.rs index bbaad31c4..a78c80fa5 100644 --- a/service/funding-bridge/src/lib.rs +++ b/service/funding-bridge/src/lib.rs @@ -47,9 +47,11 @@ pub mod app; pub mod bridge; +pub mod bridge_transport; pub mod config; pub mod error; pub mod external; +pub mod hot_relayer; pub mod intents; pub mod metrics; pub mod routes; diff --git a/service/funding-bridge/src/routes/health.rs b/service/funding-bridge/src/routes/health.rs index 5541b58e2..400bc30c8 100644 --- a/service/funding-bridge/src/routes/health.rs +++ b/service/funding-bridge/src/routes/health.rs @@ -151,6 +151,8 @@ mod tests { bridge_client, token_registry, external_chains: std::sync::Arc::new(crate::external::ExternalChainRegistry::new()), + bridge_relayer: None, + bridge_relayer_auth_token: None, config: Arc::new(args), dry_run: false, version: "0.1.0-test", diff --git a/service/funding-bridge/src/routes/mod.rs b/service/funding-bridge/src/routes/mod.rs index d9ed0485b..7b22c59d5 100644 --- a/service/funding-bridge/src/routes/mod.rs +++ b/service/funding-bridge/src/routes/mod.rs @@ -4,6 +4,7 @@ pub mod deposit; pub mod health; pub mod metrics; pub mod models; +pub mod relay; #[cfg(test)] pub mod test_utils; pub mod tokens; @@ -21,6 +22,14 @@ pub fn create_router() -> Router { .route("/metrics", routing::get(metrics::metrics)) .route("/deposit", routing::post(deposit::deposit)) .route("/withdraw", routing::post(withdraw::withdraw)) + .route( + "/relay/deposit/complete", + routing::post(relay::complete_deposit), + ) + .route( + "/relay/withdrawal/complete", + routing::post(relay::complete_withdrawal), + ) .route("/tokens/lookup", routing::get(tokens::token_lookup)) } diff --git a/service/funding-bridge/src/routes/relay.rs b/service/funding-bridge/src/routes/relay.rs new file mode 100644 index 000000000..239a693e4 --- /dev/null +++ b/service/funding-bridge/src/routes/relay.rs @@ -0,0 +1,234 @@ +use axum::{ + extract::State, + http::{header::AUTHORIZATION, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + app::App, + hot_relayer::{ + HotRelayerError, PendingWithdrawal, StellarDepositEvent, StellarWithdrawExecution, + }, +}; + +#[derive(Debug, Clone, Deserialize)] +pub struct CompleteDepositRequest { + pub event: StellarDepositEvent, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CompleteDepositResponse { + pub signature: String, + pub signed_receiver: String, + pub signed_nonce: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CompleteWithdrawalRequest { + pub pending: PendingWithdrawal, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CompleteWithdrawalResponse { + pub execution: StellarWithdrawExecution, +} + +#[tracing::instrument(name = "relay_complete_deposit", skip(app, req))] +pub async fn complete_deposit( + State(app): State, + headers: HeaderMap, + Json(req): Json, +) -> Response { + if let Some(response) = require_relay_auth(&app, &headers) { + return response; + } + + let Some(relayer) = app.bridge_relayer.as_ref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "error": "Bridge relayer backend is not configured" + })), + ) + .into_response(); + }; + + match relayer.complete_deposit(&req.event).await { + Ok(result) => ( + StatusCode::OK, + Json(CompleteDepositResponse { + signature: result.signature, + signed_receiver: result.sign_request.receiver_id, + signed_nonce: result.sign_request.nonce, + }), + ) + .into_response(), + Err(error) => map_relayer_error(error), + } +} + +#[tracing::instrument(name = "relay_complete_withdrawal", skip(app, req))] +pub async fn complete_withdrawal( + State(app): State, + headers: HeaderMap, + Json(req): Json, +) -> Response { + if let Some(response) = require_relay_auth(&app, &headers) { + return response; + } + + let Some(relayer) = app.bridge_relayer.as_ref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "error": "Bridge relayer backend is not configured" + })), + ) + .into_response(); + }; + + match relayer.complete_withdrawal(&req.pending).await { + Ok(execution) => ( + StatusCode::OK, + Json(CompleteWithdrawalResponse { execution }), + ) + .into_response(), + Err(error) => map_relayer_error(error), + } +} + +fn map_relayer_error(error: HotRelayerError) -> Response { + let status = match &error { + HotRelayerError::UnexpectedReceiver { .. } => StatusCode::BAD_REQUEST, + HotRelayerError::Http(_) + | HotRelayerError::HttpStatus { .. } + | HotRelayerError::Decode(_) => StatusCode::BAD_GATEWAY, + }; + + ( + status, + Json(serde_json::json!({ + "error": error.to_string(), + })), + ) + .into_response() +} + +fn require_relay_auth(app: &App, headers: &HeaderMap) -> Option { + let Some(expected_token) = app.bridge_relayer_auth_token.as_deref() else { + return None; + }; + + let expected_header = format!("Bearer {expected_token}"); + let is_authorized = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|actual| actual == expected_header); + + if is_authorized { + None + } else { + Some( + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "Unauthorized relay request" + })), + ) + .into_response(), + ) + } +} + +#[cfg(test)] +mod tests { + use axum::{ + extract::State, + http::{header::AUTHORIZATION, HeaderMap, HeaderValue, StatusCode}, + }; + + use super::*; + use crate::routes::test_utils::create_test_app; + + #[tokio::test] + async fn returns_503_when_backend_not_configured_deposit() { + let app = create_test_app(); + let req = CompleteDepositRequest { + event: StellarDepositEvent { + chain_id: 1100, + nonce: "1".to_string(), + sender_id: "G".to_string(), + receiver_id: "vault-counterparty.near".to_string(), + token_id: "1100_token".to_string(), + amount: "1".to_string(), + }, + }; + + let response = complete_deposit(State(app), HeaderMap::new(), Json(req)).await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[tokio::test] + async fn returns_503_when_backend_not_configured_withdrawal() { + let app = create_test_app(); + let req = CompleteWithdrawalRequest { + pending: PendingWithdrawal { + nonce: "1".to_string(), + chain_id: 1100, + withdraw_data: crate::hot_relayer::PendingWithdrawData { + receiver_id: "GADAPTER".to_string(), + amount: "1".to_string(), + token_id: "1100_token".to_string(), + }, + }, + }; + + let response = complete_withdrawal(State(app), HeaderMap::new(), Json(req)).await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[tokio::test] + async fn returns_401_when_auth_token_is_configured_and_missing_header() { + let mut app = create_test_app(); + app.bridge_relayer_auth_token = Some("relay-secret".to_string()); + let req = CompleteDepositRequest { + event: StellarDepositEvent { + chain_id: 1100, + nonce: "1".to_string(), + sender_id: "G".to_string(), + receiver_id: "vault-counterparty.near".to_string(), + token_id: "1100_token".to_string(), + amount: "1".to_string(), + }, + }; + + let response = complete_deposit(State(app), HeaderMap::new(), Json(req)).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn returns_503_when_auth_token_is_configured_and_header_is_valid_but_backend_missing() { + let mut app = create_test_app(); + app.bridge_relayer_auth_token = Some("relay-secret".to_string()); + let req = CompleteDepositRequest { + event: StellarDepositEvent { + chain_id: 1100, + nonce: "1".to_string(), + sender_id: "G".to_string(), + receiver_id: "vault-counterparty.near".to_string(), + token_id: "1100_token".to_string(), + amount: "1".to_string(), + }, + }; + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer relay-secret"), + ); + + let response = complete_deposit(State(app), headers, Json(req)).await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } +} diff --git a/service/funding-bridge/src/routes/test_utils.rs b/service/funding-bridge/src/routes/test_utils.rs index ed384ef4c..a2304df76 100644 --- a/service/funding-bridge/src/routes/test_utils.rs +++ b/service/funding-bridge/src/routes/test_utils.rs @@ -48,6 +48,8 @@ pub fn create_test_app() -> App { bridge_client, token_registry, external_chains: std::sync::Arc::new(crate::external::ExternalChainRegistry::new()), + bridge_relayer: None, + bridge_relayer_auth_token: None, config: Arc::new(args), dry_run: false, version: "0.1.0-test", diff --git a/service/funding-bridge/src/routes/tokens.rs b/service/funding-bridge/src/routes/tokens.rs index 7ed4df203..66bae490e 100644 --- a/service/funding-bridge/src/routes/tokens.rs +++ b/service/funding-bridge/src/routes/tokens.rs @@ -214,6 +214,8 @@ mod tests { bridge_client, token_registry, external_chains: std::sync::Arc::new(crate::external::ExternalChainRegistry::new()), + bridge_relayer: None, + bridge_relayer_auth_token: None, config: Arc::new(args), dry_run: false, version: "0.1.0-test", diff --git a/service/funding-bridge/src/routes/withdraw.rs b/service/funding-bridge/src/routes/withdraw.rs index 5edd14507..55f336e4a 100644 --- a/service/funding-bridge/src/routes/withdraw.rs +++ b/service/funding-bridge/src/routes/withdraw.rs @@ -469,6 +469,8 @@ mod tests { bridge_client, token_registry, external_chains: std::sync::Arc::new(crate::external::ExternalChainRegistry::new()), + bridge_relayer: None, + bridge_relayer_auth_token: None, config: Arc::new(args), dry_run: false, version: "0.1.0-test", diff --git a/service/funding-bridge/tests/tests.rs b/service/funding-bridge/tests/tests.rs index adb60c5eb..a73120138 100644 --- a/service/funding-bridge/tests/tests.rs +++ b/service/funding-bridge/tests/tests.rs @@ -264,6 +264,8 @@ async fn test_app_initialization() { external_chains: std::sync::Arc::new( templar_funding_bridge::external::ExternalChainRegistry::new(), ), + bridge_relayer: None, + bridge_relayer_auth_token: None, config: Arc::new(args), dry_run: false, version: "0.1.0-test", From ff6f5333239d7b478a30e599b318a253a53cf3a1 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 14 Apr 2026 17:15:06 +0200 Subject: [PATCH 02/18] feat(vault-counterparty): prove HOT bridge deposit and withdrawal paths --- Cargo.lock | 2 + contract/vault-counterparty/.env.example | 18 +- contract/vault-counterparty/Cargo.toml | 2 + contract/vault-counterparty/src/lib.rs | 65 +++++++- plans/CROSS_CHAIN_BRIDGE.md | 48 ++++++ .../scripts/complete_hot_deposit.sh | 155 ++++++++++++++++++ .../scripts/hot_stellar_contract_deposit.sh | 69 ++++++++ .../scripts/monitor_hot_withdrawal.sh | 116 +++++++++++++ 8 files changed, 467 insertions(+), 8 deletions(-) create mode 100755 service/funding-bridge/scripts/complete_hot_deposit.sh create mode 100755 service/funding-bridge/scripts/hot_stellar_contract_deposit.sh create mode 100755 service/funding-bridge/scripts/monitor_hot_withdrawal.sh diff --git a/Cargo.lock b/Cargo.lock index ab6bb3bd3..92f0a4af5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13241,7 +13241,9 @@ dependencies = [ name = "templar-vault-counterparty-contract" version = "0.1.0" dependencies = [ + "bs58 0.5.1", "near-sdk", + "stellar-xdr", ] [[package]] diff --git a/contract/vault-counterparty/.env.example b/contract/vault-counterparty/.env.example index 7c13ead93..542ec7bd9 100644 --- a/contract/vault-counterparty/.env.example +++ b/contract/vault-counterparty/.env.example @@ -1,15 +1,27 @@ -# Required for all mainnet counterparty flows +# Vault counterparty spike environment +# Mainnet flow from `just help`: +# 1) just bootstrap +# 2) just bridge-preflight +# 3) perform Stellar -> HOT locker deposit so omni asset mints to COUNTERPARTY_ACCOUNT on NEAR +# 4) just smoke-bridge + +# Required for deploy + init + bridge smoke flow COUNTERPARTY_ACCOUNT=your-counterparty.near OWNER_ACCOUNT=your-owner.near CURATOR_ACCOUNT=your-curator.near NEAR_MARKET=your-market.near + +# Stellar destination used by `withdraw_to_stellar` STELLAR_RECEIVER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# HOT omni asset expected to appear on NEAR after Stellar locker deposit completes OMNI_TOKEN_ID=1100_CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -# Required for synthetic funding flow (`just fund` / `just smoke-synthetic`) +# Required only for the synthetic funding path (`just fund` / `just smoke-synthetic`) FUNDER_ACCOUNT=your-funder.near -# Required to run withdraw recipes (explicit safety acknowledgement) +# Required only when running `just withdraw` / `just smoke-bridge` +# Set this after confirming Stellar locker backing liquidity for the withdraw leg WITHDRAW_LIQUIDITY_ACK=YES # Optional overrides (defaults shown) diff --git a/contract/vault-counterparty/Cargo.toml b/contract/vault-counterparty/Cargo.toml index 52a5c26e3..0e437c3ff 100644 --- a/contract/vault-counterparty/Cargo.toml +++ b/contract/vault-counterparty/Cargo.toml @@ -10,6 +10,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] near-sdk.workspace = true +bs58 = "0.5" +stellar-xdr = { version = "24.0", default-features = false, features = ["curr", "std"] } [dev-dependencies] near-sdk = { workspace = true, features = ["unit-testing"] } diff --git a/contract/vault-counterparty/src/lib.rs b/contract/vault-counterparty/src/lib.rs index 5f429b943..b916a3fea 100644 --- a/contract/vault-counterparty/src/lib.rs +++ b/contract/vault-counterparty/src/lib.rs @@ -7,6 +7,8 @@ use near_sdk::{ serde_json::{self, json}, AccountId, Gas, NearToken, PanicOnDefault, Promise, }; +use std::str::FromStr; +use stellar_xdr::curr::{Limited, Limits, ScAddress, ScVal, WriteXdr}; const ONE_YOCTO: NearToken = NearToken::from_yoctonear(1); const GAS_MT_TRANSFER: Gas = Gas::from_tgas(50); @@ -71,7 +73,7 @@ impl Contract { "mt_transfer", json!({ "receiver_id": self.near_market, - "token_id": self.omni_token_id, + "token_id": self.omni_token_id_for_contract(), "amount": amount, }), GAS_MT_TRANSFER, @@ -85,8 +87,8 @@ impl Contract { self.call_omni( "withdraw", json!({ - "token_id": self.omni_token_id, - "receiver_id": self.stellar_receiver, + "token_id": self.omni_token_id_for_contract(), + "receiver_id": self.encoded_stellar_receiver(), "amount": amount, }), GAS_WITHDRAW, @@ -159,6 +161,34 @@ impl Contract { require!(amount.0 > 0, "amount must be > 0"); } + fn omni_token_id_for_contract(&self) -> String { + let wrapped_prefix = format!("nep245:{}:", self.omni_contract); + self.omni_token_id + .strip_prefix(&wrapped_prefix) + .unwrap_or(&self.omni_token_id) + .to_string() + } + + fn encoded_stellar_receiver(&self) -> String { + let sc_address = self.parse_stellar_receiver(); + Self::encode_stellar_sc_address(&sc_address) + } + + fn parse_stellar_receiver(&self) -> ScAddress { + ScAddress::from_str(&self.stellar_receiver) + .unwrap_or_else(|_| env::panic_str("invalid stellar receiver")) + } + + fn encode_stellar_sc_address(sc_address: &ScAddress) -> String { + let sc_val = ScVal::Address(sc_address.clone()); + let mut xdr_bytes = Vec::new(); + let mut limited_writer = Limited::new(&mut xdr_bytes, Limits::none()); + sc_val + .write_xdr(&mut limited_writer) + .unwrap_or_else(|_| env::panic_str("failed to encode stellar receiver")); + bs58::encode(xdr_bytes).into_string() + } + fn assert_token_id(token_id: &str) { require!(!token_id.trim().is_empty(), "token_id cannot be empty"); require!( @@ -209,7 +239,7 @@ mod tests { fn test_contract() -> Contract { Contract::new( - "GCYV3WBXWJY4UVQZ6X3I6LBKAKP4YB6ESQOKJP4MZ2S2BFOFGB2P4D7F".to_string(), + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), account("templar-market.near"), "1100_stellar_usdc".to_string(), account("v2_1.omni.hot.tg"), @@ -265,6 +295,28 @@ mod tests { assert_eq!(args["amount"], "42"); } + #[test] + fn wrapped_token_id_is_normalized_for_omni_calls() { + let contract = Contract::new( + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + account("templar-market.near"), + "nep245:v2_1.omni.hot.tg:1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz" + .to_string(), + account("v2_1.omni.hot.tg"), + account("curator.near"), + account("owner.near"), + ); + context(&account("curator.near")); + + contract.withdraw_to_stellar(U128(1)); + + let (_, _, args, _, _) = first_function_call(); + assert_eq!( + args["token_id"], + "1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz" + ); + } + #[test] fn withdraw_to_stellar_uses_hardcoded_receiver_and_token() { let contract = test_contract(); @@ -279,7 +331,10 @@ mod tests { assert_eq!(prepaid_gas, GAS_WITHDRAW); assert_eq!( args["receiver_id"], - "GCYV3WBXWJY4UVQZ6X3I6LBKAKP4YB6ESQOKJP4MZ2S2BFOFGB2P4D7F" + Contract::encode_stellar_sc_address( + &ScAddress::from_str("GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV") + .unwrap_or_else(|_| panic!("invalid test stellar receiver")) + ) ); assert_eq!(args["token_id"], "1100_stellar_usdc"); assert_eq!(args["amount"], "999"); diff --git a/plans/CROSS_CHAIN_BRIDGE.md b/plans/CROSS_CHAIN_BRIDGE.md index 0d0221931..2ed371fb1 100644 --- a/plans/CROSS_CHAIN_BRIDGE.md +++ b/plans/CROSS_CHAIN_BRIDGE.md @@ -21,6 +21,9 @@ Ship with HOT Bridge now, but keep transport swappable. - `omni_token_id` - `omni_contract` - Runtime methods do not accept arbitrary receiver/token/action payload overrides. +- The Stellar bridge path should present as a market-like adapter to vault curation flows. +- From the curator/allocator point of view, a "deposit" into that adapter means routing funds into the bridge counterparty path, not depositing into a real yield market on Stellar. +- Operationally, the adapter "pretends" to be a market while actually transferring assets on NEAR into the counterparty/bridge route. ### Relayer Boundary @@ -50,3 +53,48 @@ If transport changes later, implement a new relayer adapter behind the same trai - Mainnet-only verification is acceptable with tiny amounts due to no public HOT testnet. - Confirm MPC API access/limits with HOT team. - Keep monitoring and fallback runbooks for relayer liveness. +- Verified via live bridge API queries for `carrion256.near`: Stellar deposit routing is currently stable across repeated requests, returning the same deposit address `GDJ4JZXZELZD737NVFORH4PSSQDWFDZTKW3AIDKHYQG23ZXBPDGGQBJK` and memo `49425867` on consecutive calls. +- For the current spike, the Stellar-side integration can treat that HOT locker address + memo pair as effectively static for `carrion256.near`, while preserving the option to re-query if HOT changes routing behavior later. + +## Verified Mainnet Transaction Trail + +- Stellar proof-of-control transfer from the generated test account back to the funder: + - tx: `4dbb21cbed36c30eb7620d70524f7d3acf73eee71ed443fc4fde61cc537dbd1f` + - result: successful 1 XLM payment from `GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV` to `GD3SOHKDS7CDGDOTJKP6VNAOEXC3Y5BRWD3WIEK65ZQAJUMTBGE4TVBZ` +- NEAR proof-of-control transfer from the implicit account back to `carrion256.tg`: + - tx: `6hrJEufLstUX3KuHsCUaU9YYMYCtZnTpSQpLzNLBaeoM` + - result: successful 0.9 NEAR transfer from `abf8a4e3650dd797a8bce5cb46b22fa791473f6fb17c5e6e4584e301cb8fffd6` to `carrion256.tg` +- NEAR named account creation for the counterparty: + - tx: `CkAQwwt3vrfcbuPqXQ2mpDqvVaAVdDZUKyXtJWGNY5wm` + - result: `carrion256.near` created on mainnet +- NEAR funding transfer into `carrion256.near` for bootstrap: + - tx: `2i6U5G2ZFSDYSYncKck39uqGRkrpxVu9rLyzyLkdh8Ha` + - result: successful 5 NEAR transfer into `carrion256.near` +- Counterparty contract deploy to `carrion256.near` with corrected `cargo near` artifact: + - tx: `6FWCW3ErGaH2DmgsKFigGkMAXSc6f182mWaLP5eiRtyr` + - result: contract code deployed successfully +- Counterparty contract initialization on NEAR: + - tx: `AnjCXStSps8Exe6VjkhY5NnjhQK9ZutMVdKke2MuTbsw` + - result: config stored for `stellar_receiver`, `near_market`, `omni_token_id`, `omni_contract`, `owner`, and `curator` +- HOT omni storage registration for `ixlm-ixlmusdc.v1.tmplr.near`: + - completed via signed transaction broadcast after near-cli-rs nonce issues + - verification: `storage_balance_of("ixlm-ixlmusdc.v1.tmplr.near")` returned present on-chain +- HOT omni storage registration for `carrion256.near`: + - completed via fresh signed transaction broadcast after nonce collision recovery + - verification: `storage_balance_of("carrion256.near")` returned present on-chain +- Stellar HOT locker deposit of native XLM into HOT's Soroban locker: + - tx: `56876328c88bd52755fa4cc3346442e9bace1adf3e962c57d5a0cddb719b9615` + - contract: `CCLWL5NYSV2WJQ3VBU44AMDHEVKEPA45N2QP2LL62O3JVKPGWWAQUVAG` + - token contract: `CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA` + - amount: `1000000` stroops (`0.1 XLM`) + - result: successful locker deposit from `GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV` + - returned nonce: `1776173770273628119000` +- HOT bridge completion into NEAR: + - tx: `2TTZViJT3zBHkt1LaoADJZf56ouS9yBZcaZUqa3xmaQw` + - sender: `0here.tg` + - result: final on NEAR + - on-chain effects: + - `v2_1.omni.hot.tg` minted `1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz` + - `v2_1.omni.hot.tg` transferred that amount to `intents.near` + - `intents.near` minted `nep245:v2_1.omni.hot.tg:1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz` to `carrion256.near` + - minted amount on `carrion256.near`: `1000000` diff --git a/service/funding-bridge/scripts/complete_hot_deposit.sh b/service/funding-bridge/scripts/complete_hot_deposit.sh new file mode 100755 index 000000000..c8dce9e8b --- /dev/null +++ b/service/funding-bridge/scripts/complete_hot_deposit.sh @@ -0,0 +1,155 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +show_usage() { + cat <<'EOF' +Usage: + ./scripts/complete_hot_deposit.sh [receiver_id] + +Arguments: + nonce HOT/Stellar deposit nonce + sender_id Stellar sender account (G...) + token_id HOT token id (for example 1100_C...) + amount Amount as integer string in token base units + receiver_id NEAR receiver account. Defaults to HOT_RELAYER_NEAR_RECEIVER from .env + +Environment: + SERVICE_URL Default: http://localhost:3000 + BRIDGE_RELAYER_AUTH_TOKEN Optional bearer token if relay auth is enabled + HOT_RELAYER_NEAR_RECEIVER Required when receiver_id is not passed + CHAIN_ID Default: 1100 + +Example: + ./scripts/complete_hot_deposit.sh \ + 123 \ + GABCDEFG... \ + 1100_CABCDEFG... \ + 1000 \ + carrion256.near +EOF +} + +check_env_file() { + if [ ! -f "$PROJECT_DIR/.env" ]; then + print_error "Missing $PROJECT_DIR/.env" + print_warn "Create it from .env.example and set HOT relayer variables first" + exit 1 + fi +} + +load_env() { + set -a + source "$PROJECT_DIR/.env" + set +a +} + +require_health() { + if ! curl -fsS "$SERVICE_URL/health" >/dev/null; then + print_error "Funding Bridge service is not reachable at $SERVICE_URL" + print_warn "Start it first with ./scripts/run_service.sh" + exit 1 + fi +} + +build_auth_args() { + AUTH_ARGS=() + if [ -n "${BRIDGE_RELAYER_AUTH_TOKEN:-}" ]; then + AUTH_ARGS=(-H "Authorization: Bearer $BRIDGE_RELAYER_AUTH_TOKEN") + fi +} + +check_required_config() { + if [ "${BRIDGE_RELAYER_BACKEND:-none}" != "hot" ]; then + print_error "BRIDGE_RELAYER_BACKEND must be set to 'hot' in .env" + exit 1 + fi + + if [ -z "${HOT_MPC_API_URL:-}" ]; then + print_error "HOT_MPC_API_URL is required in .env" + exit 1 + fi + + if [ -z "${HOT_RELAYER_STELLAR_RECEIVER:-}" ]; then + print_error "HOT_RELAYER_STELLAR_RECEIVER is required in .env" + exit 1 + fi +} + +NONCE="${1:-}" +SENDER_ID="${2:-}" +TOKEN_ID="${3:-}" +AMOUNT="${4:-}" +RECEIVER_ID="${5:-${HOT_RELAYER_NEAR_RECEIVER:-}}" +CHAIN_ID="${CHAIN_ID:-1100}" +SERVICE_URL="${SERVICE_URL:-http://localhost:3000}" + +if [ -z "$NONCE" ] || [ -z "$SENDER_ID" ] || [ -z "$TOKEN_ID" ] || [ -z "$AMOUNT" ]; then + show_usage + exit 1 +fi + +check_env_file +load_env +RECEIVER_ID="${5:-${HOT_RELAYER_NEAR_RECEIVER:-}}" + +if [ -z "$RECEIVER_ID" ]; then + print_error "receiver_id is required either as arg 5 or HOT_RELAYER_NEAR_RECEIVER in .env" + exit 1 +fi + +check_required_config +build_auth_args +require_health + +JSON_PAYLOAD="$(python3 - <&2 + exit 1 +fi + +set -a +source "$PROJECT_DIR/.env" +set +a + +if [ -z "${STELLAR_SECRET_KEY:-}" ]; then + echo "STELLAR_SECRET_KEY is required in $PROJECT_DIR/.env" >&2 + exit 1 +fi + +if [ "$ASSET_KIND" != "native" ]; then + echo "Only native XLM is supported by this proof script right now" >&2 + exit 1 +fi + +SENDER_ACCOUNT="$(stellar keys address templar-hot-mainnet)" +TOKEN_CONTRACT="$(stellar contract id asset --asset native --rpc-url "$STELLAR_RPC_URL" --network-passphrase "$STELLAR_NETWORK_PASSPHRASE")" +CLIENT_TIMESTAMP="$(python3 - <<'PY' +import time +print(time.time_ns() - 20_000_000_000) +PY +)" +RECEIVER_HEX="$(python3 - <&2 + exit 1 +fi + +ENCODED_RECEIVER="$(python3 - <<'PY' "$RECEIVER" +import sys +alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' +addr = sys.argv[1] +try: + import subprocess, tempfile, pathlib, os + root = pathlib.Path('/data/projects/t-private-fork') + bs58 = next((root / 'target' / 'debug' / 'deps').glob('libbs58-*.rlib')) + stellar_xdr = next((root / 'target' / 'debug' / 'deps').glob('libstellar_xdr-*.rlib')) + src = tempfile.NamedTemporaryFile('w', suffix='.rs', delete=False) + src.write('use std::str::FromStr; use stellar_xdr::curr::{Limited, Limits, ScAddress, ScVal, WriteXdr}; fn main(){ let addr=std::env::args().nth(1).unwrap(); let sc=ScAddress::from_str(&addr).unwrap(); let val=ScVal::Address(sc); let mut bytes=Vec::new(); let mut w=Limited::new(&mut bytes, Limits::none()); val.write_xdr(&mut w).unwrap(); println!("{}", bs58::encode(bytes).into_string()); }') + src.close() + bin_path = src.name + '.bin' + subprocess.check_call([ + 'rustc', src.name, '-o', bin_path, + '--extern', f'bs58={bs58}', + '--extern', f'stellar_xdr={stellar_xdr}', + '-L', f'dependency={root / "target" / "debug" / "deps"}', + '--edition=2021' + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + out = subprocess.check_output([bin_path, addr], text=True).strip() + print(out) + os.unlink(src.name) + os.unlink(bin_path) +except Exception: + print('') +PY +)" + +while true; do + echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') =====" + echo "nonce: $NONCE" + echo "receiver: $RECEIVER" + echo "encoded_receiver: $ENCODED_RECEIVER" + + echo "-- stellar balance --" + python3 - <<'PY' "$RECEIVER" +import sys, json, urllib.request +addr=sys.argv[1] +url=f'https://horizon.stellar.org/accounts/{addr}' +with urllib.request.urlopen(url, timeout=30) as r: + data=json.load(r) +for b in data.get('balances', []): + if b.get('asset_type') == 'native': + print(b.get('balance')) +PY + + echo "-- hot withdraw/sign --" + node - <<'JS' "$NONCE" +const nonce = process.argv[2]; +for (const url of ['https://rpc1.hotdao.ai/withdraw/sign','https://rpc2.hotdao.ai/withdraw/sign']) { + try { + const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({nonce})}); + console.log(url, res.status, await res.text()); + } catch (e) { + console.log(url, 'ERR', String(e)); + } +} +JS + + echo "-- hot clear_completed_withdrawal --" + node - <<'JS' "$NONCE" +const nonce = process.argv[2]; +const headers = {'omni-version':'v2','Content-Type':'application/json','Referer':'https://near-intents.org','Origin':'https://near-intents.org'}; +for (const url of ['https://api0.herewallet.app/api/v1/transactions/clear_completed_withdrawal','https://api2.herewallet.app/api/v1/transactions/clear_completed_withdrawal']) { + try { + const res = await fetch(url, {method:'POST', headers, body: JSON.stringify({nonce})}); + console.log(url, res.status, await res.text()); + } catch (e) { + console.log(url, 'ERR', String(e)); + } +} +JS + + if [ -n "$ENCODED_RECEIVER" ]; then + echo "-- near pending withdrawals --" + python3 - <<'PY' "$ENCODED_RECEIVER" +import sys, json, urllib.request, base64 +encoded = sys.argv[1] +args = {'receiver_id': encoded, 'chain_id': 1100} +payload={"jsonrpc":"2.0","id":"dontcare","method":"query","params":{"request_type":"call_function","finality":"final","account_id":"v2_1.omni.hot.tg","method_name":"get_withdrawals_by_receiver","args_base64":base64.b64encode(json.dumps(args).encode()).decode()}} +req=urllib.request.Request('https://rpc.mainnet.near.org', data=json.dumps(payload).encode(), headers={'Content-Type':'application/json'}) +with urllib.request.urlopen(req, timeout=60) as r: + print(r.read().decode()) +PY + fi + + if [ "$ONCE" = "1" ]; then + break + fi + + sleep "$INTERVAL" +done From 9aac4be69470bc6ad8bab45a9d55dd2d48a3040d Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 15 Apr 2026 17:59:14 +0200 Subject: [PATCH 03/18] docs(bridge): record withdrawal investigation notes --- plans/CROSS_CHAIN_BRIDGE.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/plans/CROSS_CHAIN_BRIDGE.md b/plans/CROSS_CHAIN_BRIDGE.md index 2ed371fb1..066659bb1 100644 --- a/plans/CROSS_CHAIN_BRIDGE.md +++ b/plans/CROSS_CHAIN_BRIDGE.md @@ -98,3 +98,36 @@ If transport changes later, implement a new relayer adapter behind the same trai - `v2_1.omni.hot.tg` transferred that amount to `intents.near` - `intents.near` minted `nep245:v2_1.omni.hot.tg:1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz` to `carrion256.near` - minted amount on `carrion256.near`: `1000000` + +## Withdrawal Notes + +- Initial direct withdrawal attempt from `carrion256.near` to HOT failed because the counterparty passed the raw Stellar `G...` address into HOT `withdraw(...)`. + - failure mode: HOT parse error on receiver format + - fix: encode the Stellar receiver as base58 XDR `ScVal(Address)` before calling HOT +- After fixing receiver encoding, the direct withdrawal attempt still failed because the counterparty passed the wrapped intents token ID (`nep245:v2_1.omni.hot.tg:...`) into HOT `withdraw(...)`. + - failure mode: `Failed to parse chain ID 'nep245:v2'` + - fix: normalize the wrapped token ID down to the raw HOT omni token ID before direct HOT calls +- Updated counterparty contract with both fixes: + - deploy tx: `AGhfPGre4pcpgxAEchV4cMEtsMCSThSXLjJHno2BUuFV` + - resulting on-chain code hash: `9Sct9sHHWK3VA6Qr9z4qgT1bSxTztecnY3VPh1fLg3cX` +- Even after those fixes, direct `withdraw_to_stellar` from the counterparty still failed with `Not enough balance`. + - root cause: the bridged asset balance lives on `intents.near` as a NEP-245 wrapped token, not as a raw balance directly owned by `carrion256.near` on `v2_1.omni.hot.tg` + - verification: + - `intents.near mt_balance_of(carrion256.near, nep245:...) = 1000000` + - `v2_1.omni.hot.tg mt_balance_of(carrion256.near, 1100_...) = 0` +- Direct withdrawal from the correct layer succeeded using `intents.near mt_withdraw` with the raw HOT token contract + token id: + - tx: `FZixtw3Sr97TWD7dnmDJApX5yGCXEbXTdzbB1fsGEenx` + - args shape that worked: + - `token = "v2_1.omni.hot.tg"` + - `token_ids = ["1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz"]` + - `receiver_id = "bridge-refuel.hot.tg"` + - `msg.receiver_id = "1114wxgAxsZMgcipUZ92Tv8iTpTqiyZMoQqC9kWRbj8jVURaZcnBS1uQCiL"` + - on-chain effects: + - `intents.near` burned `500000` of `nep245:v2_1.omni.hot.tg:1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz` from `carrion256.near` + - `v2_1.omni.hot.tg` transferred `500000` of raw omni token `1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz` to `bridge-refuel.hot.tg` + - `v2_1.omni.hot.tg` emitted `mt_burn` from `bridge-refuel.hot.tg` with memo / withdrawal nonce `1776177129000001087571` +- Final target-chain unlock is still blocked on HOT backend exposure of that withdrawal nonce: + - `rpc1.hotdao.ai/withdraw/sign` and `rpc2.hotdao.ai/withdraw/sign` both return `404 {"detail":"Nonce not found or vas already cleared"}` for nonce `1776177129000001087571` + - `clear_completed_withdrawal` returns `false` + - `v2_1.omni.hot.tg.get_withdrawals_by_receiver(...)` returns `[]` + - current interpretation: NEAR-side burn/handoff succeeded, but HOT has not yet surfaced a signer-ready Stellar withdrawal record for the nonce From be214e1ea3c683b5eb3bd3fa76422f806c100dff Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 5 Jun 2026 16:59:32 +0200 Subject: [PATCH 04/18] chore: normalize counterparty lockfile --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 92f0a4af5..2c0f23fd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13243,7 +13243,7 @@ version = "0.1.0" dependencies = [ "bs58 0.5.1", "near-sdk", - "stellar-xdr", + "stellar-xdr 24.0.1", ] [[package]] From 40299d2a03f33409ca77646a60289bd54e029526 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 20 Apr 2026 10:42:11 +0200 Subject: [PATCH 05/18] fix(vault-counterparty): route stellar withdraws through intents --- contract/vault-counterparty/src/lib.rs | 187 ++++++++++++++++--------- plans/CROSS_CHAIN_BRIDGE.md | 4 + 2 files changed, 124 insertions(+), 67 deletions(-) diff --git a/contract/vault-counterparty/src/lib.rs b/contract/vault-counterparty/src/lib.rs index b916a3fea..44a13a0b2 100644 --- a/contract/vault-counterparty/src/lib.rs +++ b/contract/vault-counterparty/src/lib.rs @@ -12,7 +12,10 @@ use stellar_xdr::curr::{Limited, Limits, ScAddress, ScVal, WriteXdr}; const ONE_YOCTO: NearToken = NearToken::from_yoctonear(1); const GAS_MT_TRANSFER: Gas = Gas::from_tgas(50); -const GAS_WITHDRAW: Gas = Gas::from_tgas(80); +const GAS_WITHDRAW_TARGET: Gas = Gas::from_tgas(250); +const GAS_WITHDRAW_BUFFER: Gas = Gas::from_tgas(20); +const INTENTS_CONTRACT: &str = "intents.near"; +const BRIDGE_REFUEL_ACCOUNT: &str = "bridge-refuel.hot.tg"; const MAX_STELLAR_RECEIVER_LEN: usize = 256; const MAX_TOKEN_ID_LEN: usize = 256; @@ -84,53 +87,45 @@ impl Contract { self.assert_curator(); Self::assert_amount(amount); - self.call_omni( - "withdraw", - json!({ - "token_id": self.omni_token_id_for_contract(), - "receiver_id": self.encoded_stellar_receiver(), - "amount": amount, - }), - GAS_WITHDRAW, + let remaining_gas = Gas::from_gas( + env::prepaid_gas() + .as_gas() + .saturating_sub(env::used_gas().as_gas()) + .saturating_sub(GAS_WITHDRAW_BUFFER.as_gas()), + ); + let forwarded_gas = Gas::from_gas(remaining_gas.as_gas().min(GAS_WITHDRAW_TARGET.as_gas())); + + Promise::new(account_id(INTENTS_CONTRACT)).function_call( + "mt_withdraw".to_string(), + serde_json::to_vec(&json!({ + "token": self.intents_token_contract(), + "receiver_id": BRIDGE_REFUEL_ACCOUNT, + "token_ids": [self.intents_multi_token_id()], + "amounts": [amount.0.to_string()], + "memo": serde_json::Value::Null, + "msg": self.withdraw_msg_json(), + })) + .unwrap_or_else(|_| env::panic_str("failed to serialize withdrawal args")), + ONE_YOCTO, + forwarded_gas, ) } - pub fn set_curator(&mut self, curator: AccountId) { - self.assert_owner(); - self.curator = curator; - env::log_str("curator_updated"); + fn withdraw_msg_json(&self) -> String { + json!({ + "receiver_id": self.encoded_stellar_receiver(), + "amount_native": "0", + "block_number": 0, + }) + .to_string() } - pub fn propose_owner(&mut self, pending_owner: AccountId) { - self.assert_owner(); - require!(pending_owner != self.owner, "new owner must differ"); - self.pending_owner = Some(pending_owner); - env::log_str("owner_proposed"); + fn intents_token_contract(&self) -> String { + self.omni_contract.to_string() } - pub fn accept_owner(&mut self) { - let predecessor = env::predecessor_account_id(); - require!( - self.pending_owner - .as_ref() - .is_some_and(|pending| pending == &predecessor), - "Only pending owner can accept ownership" - ); - self.owner = predecessor; - self.pending_owner = None; - env::log_str("owner_accepted"); - } - - pub fn get_config(&self) -> Config { - Config { - stellar_receiver: self.stellar_receiver.clone(), - near_market: self.near_market.clone(), - omni_token_id: self.omni_token_id.clone(), - curator: self.curator.clone(), - owner: self.owner.clone(), - pending_owner: self.pending_owner.clone(), - omni_contract: self.omni_contract.clone(), - } + fn intents_multi_token_id(&self) -> String { + self.omni_token_id_for_contract() } fn call_omni(&self, method_name: &str, args: serde_json::Value, gas: Gas) -> Promise { @@ -143,24 +138,6 @@ impl Contract { ) } - fn assert_curator(&self) { - require!( - env::predecessor_account_id() == self.curator, - "Only curator can call this method" - ); - } - - fn assert_owner(&self) { - require!( - env::predecessor_account_id() == self.owner, - "Only owner can call this method" - ); - } - - fn assert_amount(amount: U128) { - require!(amount.0 > 0, "amount must be > 0"); - } - fn omni_token_id_for_contract(&self) -> String { let wrapped_prefix = format!("nep245:{}:", self.omni_contract); self.omni_token_id @@ -212,6 +189,71 @@ impl Contract { } } +fn account_id(value: &str) -> AccountId { + value + .parse() + .unwrap_or_else(|_| env::panic_str("invalid account id constant")) +} + +#[near] +impl Contract { + pub fn set_curator(&mut self, curator: AccountId) { + self.assert_owner(); + self.curator = curator; + env::log_str("curator_updated"); + } + + pub fn propose_owner(&mut self, pending_owner: AccountId) { + self.assert_owner(); + require!(pending_owner != self.owner, "new owner must differ"); + self.pending_owner = Some(pending_owner); + env::log_str("owner_proposed"); + } + + pub fn accept_owner(&mut self) { + let predecessor = env::predecessor_account_id(); + require!( + self.pending_owner + .as_ref() + .is_some_and(|pending| pending == &predecessor), + "Only pending owner can accept ownership" + ); + self.owner = predecessor; + self.pending_owner = None; + env::log_str("owner_accepted"); + } + + pub fn get_config(&self) -> Config { + Config { + stellar_receiver: self.stellar_receiver.clone(), + near_market: self.near_market.clone(), + omni_token_id: self.omni_token_id.clone(), + curator: self.curator.clone(), + owner: self.owner.clone(), + pending_owner: self.pending_owner.clone(), + omni_contract: self.omni_contract.clone(), + } + } + + fn assert_curator(&self) { + require!( + env::predecessor_account_id() == self.curator, + "Only curator can call this method" + ); + } + + fn assert_owner(&self) { + require!( + env::predecessor_account_id() == self.owner, + "Only owner can call this method" + ); + } + + fn assert_amount(amount: U128) { + require!(amount.0 > 0, "amount must be > 0"); + } +} + #[cfg(test)] mod tests { use near_sdk::{ @@ -234,6 +276,7 @@ mod tests { builder.current_account_id(account("counterparty.near")); builder.predecessor_account_id(predecessor.clone()); builder.signer_account_id(predecessor.clone()); + builder.prepaid_gas(Gas::from_tgas(400)); testing_env!(builder.build()); } @@ -312,7 +355,7 @@ mod tests { let (_, _, args, _, _) = first_function_call(); assert_eq!( - args["token_id"], + args["token_ids"][0], "1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz" ); } @@ -325,19 +368,29 @@ mod tests { contract.withdraw_to_stellar(U128(999)); let (receiver, method, args, attached_deposit, prepaid_gas) = first_function_call(); - assert_eq!(receiver, account("v2_1.omni.hot.tg")); - assert_eq!(method, "withdraw"); + assert_eq!(receiver, account(INTENTS_CONTRACT)); + assert_eq!(method, "mt_withdraw"); assert_eq!(attached_deposit, ONE_YOCTO); - assert_eq!(prepaid_gas, GAS_WITHDRAW); + assert_eq!(prepaid_gas, GAS_WITHDRAW_TARGET); + assert_eq!(args["token"], "v2_1.omni.hot.tg"); + assert_eq!(args["receiver_id"], BRIDGE_REFUEL_ACCOUNT); + assert_eq!(args["token_ids"][0], "1100_stellar_usdc"); + assert_eq!(args["amounts"][0], "999"); + let msg: serde_json::Value = serde_json::from_str( + args["msg"] + .as_str() + .unwrap_or_else(|| panic!("missing withdrawal msg")), + ) + .unwrap_or_else(|e| panic!("failed to parse withdrawal msg: {e}")); assert_eq!( - args["receiver_id"], + msg["receiver_id"], Contract::encode_stellar_sc_address( &ScAddress::from_str("GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV") .unwrap_or_else(|_| panic!("invalid test stellar receiver")) ) ); - assert_eq!(args["token_id"], "1100_stellar_usdc"); - assert_eq!(args["amount"], "999"); + assert_eq!(msg["amount_native"], "0"); + assert_eq!(msg["block_number"], 0); } #[test] @@ -360,7 +413,7 @@ mod tests { contract.withdraw_to_stellar(U128(5)); let (_, method, _, _, _) = first_function_call(); - assert_eq!(method, "withdraw"); + assert_eq!(method, "mt_withdraw"); } #[test] diff --git a/plans/CROSS_CHAIN_BRIDGE.md b/plans/CROSS_CHAIN_BRIDGE.md index 066659bb1..4f12c5753 100644 --- a/plans/CROSS_CHAIN_BRIDGE.md +++ b/plans/CROSS_CHAIN_BRIDGE.md @@ -55,6 +55,10 @@ If transport changes later, implement a new relayer adapter behind the same trai - Keep monitoring and fallback runbooks for relayer liveness. - Verified via live bridge API queries for `carrion256.near`: Stellar deposit routing is currently stable across repeated requests, returning the same deposit address `GDJ4JZXZELZD737NVFORH4PSSQDWFDZTKW3AIDKHYQG23ZXBPDGGQBJK` and memo `49425867` on consecutive calls. - For the current spike, the Stellar-side integration can treat that HOT locker address + memo pair as effectively static for `carrion256.near`, while preserving the option to re-query if HOT changes routing behavior later. +- For contract-based HOT Stellar deposits targeting `carrion256.near`, reuse the first proven-good receiver bytes instead of recomputing during the spike: + - receiver hex: `52fd581de41f4bace88c936b89bf267a1161426a466adc518cd9e56f201651dd` + - receiver base58: `6axVRkdWZFh81dbLC4bBCqHqWhsD7r4u7Rv52D4vshbA` + - rationale: a later recomputation produced different bytes and HOT rejected `process_bridge_deposit` with `Cant sign by HOT Verify` ## Verified Mainnet Transaction Trail From ab24d44d1285d03c36347a06af3151282ec42bb8 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 3 Jun 2026 13:27:14 +0200 Subject: [PATCH 06/18] feat(hot): split HOT relayer service --- Cargo.lock | 35 + Cargo.toml | 1 + contract/vault-counterparty/src/lib.rs | 185 +++- service/funding-bridge/.env.example | 14 - service/funding-bridge/README.md | 68 +- service/funding-bridge/src/app.rs | 92 -- service/funding-bridge/src/hot_relayer.rs | 463 ---------- service/funding-bridge/src/lib.rs | 2 - service/funding-bridge/src/routes/health.rs | 2 - service/funding-bridge/src/routes/mod.rs | 9 - service/funding-bridge/src/routes/relay.rs | 234 ----- .../funding-bridge/src/routes/test_utils.rs | 2 - service/funding-bridge/src/routes/tokens.rs | 2 - service/funding-bridge/src/routes/withdraw.rs | 2 - service/funding-bridge/tests/tests.rs | 2 - service/hot-relayer/.env.example | 9 + service/hot-relayer/Cargo.toml | 34 + service/hot-relayer/Dockerfile | 25 + service/hot-relayer/README.md | 41 + service/hot-relayer/docs/operations.md | 103 +++ .../scripts/complete_hot_deposit.sh | 36 +- .../src/bridge_transport.rs | 15 +- service/hot-relayer/src/config.rs | 344 +++++++ service/hot-relayer/src/hot_relayer.rs | 850 ++++++++++++++++++ service/hot-relayer/src/lib.rs | 15 + service/hot-relayer/src/main.rs | 99 ++ service/hot-relayer/src/metrics.rs | 21 + service/hot-relayer/src/routes.rs | 305 +++++++ 28 files changed, 2054 insertions(+), 956 deletions(-) delete mode 100644 service/funding-bridge/src/hot_relayer.rs delete mode 100644 service/funding-bridge/src/routes/relay.rs create mode 100644 service/hot-relayer/.env.example create mode 100644 service/hot-relayer/Cargo.toml create mode 100644 service/hot-relayer/Dockerfile create mode 100644 service/hot-relayer/README.md create mode 100644 service/hot-relayer/docs/operations.md rename service/{funding-bridge => hot-relayer}/scripts/complete_hot_deposit.sh (79%) rename service/{funding-bridge => hot-relayer}/src/bridge_transport.rs (90%) create mode 100644 service/hot-relayer/src/config.rs create mode 100644 service/hot-relayer/src/hot_relayer.rs create mode 100644 service/hot-relayer/src/lib.rs create mode 100644 service/hot-relayer/src/main.rs create mode 100644 service/hot-relayer/src/metrics.rs create mode 100644 service/hot-relayer/src/routes.rs diff --git a/Cargo.lock b/Cargo.lock index 2c0f23fd9..114fac8bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4326,6 +4326,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "glob" version = "0.3.3" @@ -12699,6 +12711,29 @@ dependencies = [ "templar-vault-kernel", ] +[[package]] +name = "templar-hot-relayer" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "clap", + "getset", + "near-primitives", + "prometheus", + "reqwest 0.12.28", + "serde", + "serde_json", + "stellar-xdr 26.0.0", + "thiserror 2.0.18", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "wiremock", +] + [[package]] name = "templar-liquidator" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index fcd69e2be..d20a1c1d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ derive_more = { version = "1", default-features = false, features = [ "is_variant", ] } getrandom = { version = "0.2", features = ["custom"] } +getset = "0.1.6" hex = { version = "0.4.3", features = ["serde"] } hex-literal = "0.4" itertools = "0.14.0" diff --git a/contract/vault-counterparty/src/lib.rs b/contract/vault-counterparty/src/lib.rs index 44a13a0b2..436626d01 100644 --- a/contract/vault-counterparty/src/lib.rs +++ b/contract/vault-counterparty/src/lib.rs @@ -18,6 +18,7 @@ const INTENTS_CONTRACT: &str = "intents.near"; const BRIDGE_REFUEL_ACCOUNT: &str = "bridge-refuel.hot.tg"; const MAX_STELLAR_RECEIVER_LEN: usize = 256; const MAX_TOKEN_ID_LEN: usize = 256; +const HOT_STELLAR_CHAIN_PREFIX: &str = "1100_"; #[derive(Debug, Clone)] #[near(serializers = [json])] @@ -31,12 +32,89 @@ pub struct Config { pub omni_contract: AccountId, } +#[derive(Debug, Clone)] +#[near(serializers = [borsh])] +struct StellarReceiver { + raw: String, + encoded: String, +} + +impl StellarReceiver { + fn new(receiver: String) -> Self { + require!( + !receiver.trim().is_empty(), + "stellar receiver cannot be empty" + ); + require!( + receiver.len() <= MAX_STELLAR_RECEIVER_LEN, + format!( + "stellar receiver too long, max {}", + MAX_STELLAR_RECEIVER_LEN + ) + ); + + let sc_address = ScAddress::from_str(&receiver) + .unwrap_or_else(|_| env::panic_str("invalid stellar receiver")); + let encoded = Contract::encode_stellar_sc_address(&sc_address); + Self { + raw: receiver, + encoded, + } + } + + fn as_str(&self) -> &str { + &self.raw + } + + fn encoded(&self) -> &str { + &self.encoded + } +} + +#[derive(Debug, Clone)] +#[near(serializers = [borsh])] +struct OmniTokenId(String); + +impl OmniTokenId { + fn new(token_id: String, omni_contract: &AccountId) -> Self { + require!(!token_id.trim().is_empty(), "token_id cannot be empty"); + require!( + token_id.len() <= MAX_TOKEN_ID_LEN, + format!("token_id too long, max {}", MAX_TOKEN_ID_LEN) + ); + + let wrapped_prefix = format!("nep245:{}:", omni_contract); + let normalized = token_id + .strip_prefix(&wrapped_prefix) + .unwrap_or(&token_id) + .to_string(); + require!( + !normalized.contains(':'), + "token_id cannot contain an unexpected wrapper prefix" + ); + require!( + normalized.starts_with(HOT_STELLAR_CHAIN_PREFIX) + && normalized.len() > HOT_STELLAR_CHAIN_PREFIX.len(), + format!( + "token_id must start with {} and include an asset id", + HOT_STELLAR_CHAIN_PREFIX + ) + ); + + Self(normalized) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + #[derive(PanicOnDefault)] #[near(contract_state)] pub struct Contract { - stellar_receiver: String, + stellar_receiver: StellarReceiver, near_market: AccountId, - omni_token_id: String, + omni_token_id: OmniTokenId, curator: AccountId, owner: AccountId, pending_owner: Option, @@ -54,8 +132,8 @@ impl Contract { curator: AccountId, owner: AccountId, ) -> Self { - Self::assert_receiver(&stellar_receiver); - Self::assert_token_id(&omni_token_id); + let stellar_receiver = StellarReceiver::new(stellar_receiver); + let omni_token_id = OmniTokenId::new(omni_token_id, &omni_contract); Self { stellar_receiver, @@ -113,7 +191,7 @@ impl Contract { fn withdraw_msg_json(&self) -> String { json!({ - "receiver_id": self.encoded_stellar_receiver(), + "receiver_id": self.stellar_receiver.encoded(), "amount_native": "0", "block_number": 0, }) @@ -139,21 +217,7 @@ impl Contract { } fn omni_token_id_for_contract(&self) -> String { - let wrapped_prefix = format!("nep245:{}:", self.omni_contract); - self.omni_token_id - .strip_prefix(&wrapped_prefix) - .unwrap_or(&self.omni_token_id) - .to_string() - } - - fn encoded_stellar_receiver(&self) -> String { - let sc_address = self.parse_stellar_receiver(); - Self::encode_stellar_sc_address(&sc_address) - } - - fn parse_stellar_receiver(&self) -> ScAddress { - ScAddress::from_str(&self.stellar_receiver) - .unwrap_or_else(|_| env::panic_str("invalid stellar receiver")) + self.omni_token_id.as_str().to_string() } fn encode_stellar_sc_address(sc_address: &ScAddress) -> String { @@ -165,28 +229,6 @@ impl Contract { .unwrap_or_else(|_| env::panic_str("failed to encode stellar receiver")); bs58::encode(xdr_bytes).into_string() } - - fn assert_token_id(token_id: &str) { - require!(!token_id.trim().is_empty(), "token_id cannot be empty"); - require!( - token_id.len() <= MAX_TOKEN_ID_LEN, - format!("token_id too long, max {}", MAX_TOKEN_ID_LEN) - ); - } - - fn assert_receiver(receiver: &str) { - require!( - !receiver.trim().is_empty(), - "stellar receiver cannot be empty" - ); - require!( - receiver.len() <= MAX_STELLAR_RECEIVER_LEN, - format!( - "stellar receiver too long, max {}", - MAX_STELLAR_RECEIVER_LEN - ) - ); - } } fn account_id(value: &str) -> AccountId { @@ -225,9 +267,9 @@ impl Contract { pub fn get_config(&self) -> Config { Config { - stellar_receiver: self.stellar_receiver.clone(), + stellar_receiver: self.stellar_receiver.as_str().to_string(), near_market: self.near_market.clone(), - omni_token_id: self.omni_token_id.clone(), + omni_token_id: self.omni_token_id.as_str().to_string(), curator: self.curator.clone(), owner: self.owner.clone(), pending_owner: self.pending_owner.clone(), @@ -473,7 +515,7 @@ mod tests { #[should_panic(expected = "token_id cannot be empty")] fn init_rejects_empty_token_id() { let _ = Contract::new( - "GCYV3WBXWJY4UVQZ6X3I6LBKAKP4YB6ESQOKJP4MZ2S2BFOFGB2P4D7F".to_string(), + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), account("templar-market.near"), "".to_string(), account("v2_1.omni.hot.tg"), @@ -481,4 +523,57 @@ mod tests { account("owner.near"), ); } + + #[test] + #[should_panic(expected = "invalid stellar receiver")] + fn init_rejects_invalid_stellar_receiver() { + let _ = Contract::new( + "not-a-stellar-address".to_string(), + account("templar-market.near"), + "1100_stellar_usdc".to_string(), + account("v2_1.omni.hot.tg"), + account("curator.near"), + account("owner.near"), + ); + } + + #[test] + #[should_panic(expected = "token_id must start with 1100_ and include an asset id")] + fn init_rejects_token_id_for_wrong_chain() { + let _ = Contract::new( + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + account("templar-market.near"), + "1101_stellar_usdc".to_string(), + account("v2_1.omni.hot.tg"), + account("curator.near"), + account("owner.near"), + ); + } + + #[test] + #[should_panic(expected = "token_id cannot contain an unexpected wrapper prefix")] + fn init_rejects_unexpected_token_wrapper_prefix() { + let _ = Contract::new( + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + account("templar-market.near"), + "nep245:other.omni.hot.tg:1100_stellar_usdc".to_string(), + account("v2_1.omni.hot.tg"), + account("curator.near"), + account("owner.near"), + ); + } + + #[test] + fn init_normalizes_expected_wrapped_token_id() { + let contract = Contract::new( + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + account("templar-market.near"), + "nep245:v2_1.omni.hot.tg:1100_stellar_usdc".to_string(), + account("v2_1.omni.hot.tg"), + account("curator.near"), + account("owner.near"), + ); + + assert_eq!(contract.get_config().omni_token_id, "1100_stellar_usdc"); + } } diff --git a/service/funding-bridge/.env.example b/service/funding-bridge/.env.example index 62ad37e80..8c1779485 100644 --- a/service/funding-bridge/.env.example +++ b/service/funding-bridge/.env.example @@ -21,20 +21,6 @@ NEAR_TREASURY_KEY=ed25519:YOUR_PRIVATE_KEY_HERE # ---------------------------------------------------------------------------- BRIDGE_API_URL=https://bridge.chaindefuser.com/rpc -# ---------------------------------------------------------------------------- -# Bridge Relayer Backend (Optional) -# ---------------------------------------------------------------------------- -# Set to "hot" to enable HOT bridge relayer completion endpoints. -# Supported values: none, hot -BRIDGE_RELAYER_BACKEND=none - -# Required when BRIDGE_RELAYER_BACKEND=hot -# HOT_MPC_API_URL=https://your-hot-mpc-api -# HOT_RELAYER_NEAR_RECEIVER=vault-counterparty.near -# HOT_RELAYER_STELLAR_RECEIVER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -# Optional but recommended when relay endpoints are exposed: -# BRIDGE_RELAYER_AUTH_TOKEN=replace-with-long-random-token - # ---------------------------------------------------------------------------- # External Chain Wallets (Optional) # ---------------------------------------------------------------------------- diff --git a/service/funding-bridge/README.md b/service/funding-bridge/README.md index b6a7b60a9..770664307 100644 --- a/service/funding-bridge/README.md +++ b/service/funding-bridge/README.md @@ -13,7 +13,6 @@ A NEAR-centric treasury management service with cross-chain deposits and withdra - ✅ **Token Resolution** - Automatic OMFT token ID and decimal handling - ✅ **Stateless Design** - No database required, horizontally scalable - ✅ **REST API** - Simple HTTP/JSON interface -- ✅ **Bridge Relayer Backend** - Configurable transport abstraction (`none`/`hot`) - ✅ **Prometheus Metrics** - Production-grade observability - ✅ **Dry-Run Mode** - Test operations without executing transactions @@ -54,15 +53,6 @@ export NETWORK=mainnet # Optional: Bridge API (default: https://bridge.chaindefuser.com/rpc) export BRIDGE_API_URL=https://bridge.chaindefuser.com/rpc -# Optional: Bridge relayer backend (defaults to none) -export BRIDGE_RELAYER_BACKEND=none -# export BRIDGE_RELAYER_BACKEND=hot -# export HOT_MPC_API_URL=https://your-hot-mpc-api -# export HOT_RELAYER_NEAR_RECEIVER=vault-counterparty.near -# export HOT_RELAYER_STELLAR_RECEIVER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -# Optional (recommended if service is network-exposed): -# export BRIDGE_RELAYER_AUTH_TOKEN=replace-with-long-random-token - # Run the service ./target/release/funding-bridge ``` @@ -282,56 +272,10 @@ curl "http://localhost:3000/tokens/lookup?asset=USDT&chain=ethereum" } ``` -#### 6. Relay Completion (Optional, backend-dependent) - -These endpoints are available only when a bridge relayer backend is configured. -For HOT, set: - -- `BRIDGE_RELAYER_BACKEND=hot` -- `HOT_MPC_API_URL` -- `HOT_RELAYER_NEAR_RECEIVER` -- `HOT_RELAYER_STELLAR_RECEIVER` - -If `BRIDGE_RELAYER_AUTH_TOKEN` is configured, include: - -- `Authorization: Bearer ` - -Complete deposit signature payload: - -```bash -curl -X POST http://localhost:3000/relay/deposit/complete \ - -H "Authorization: Bearer $BRIDGE_RELAYER_AUTH_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "event": { - "chain_id": 1100, - "nonce": "123", - "sender_id": "GVAULT...", - "receiver_id": "vault-counterparty.near", - "token_id": "1100_C...", - "amount": "1000" - } - }' -``` - -Complete withdrawal execution payload: +#### 6. HOT Relay Completion -```bash -curl -X POST http://localhost:3000/relay/withdrawal/complete \ - -H "Authorization: Bearer $BRIDGE_RELAYER_AUTH_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "pending": { - "nonce": "123", - "chain_id": 1100, - "withdraw_data": { - "receiver_id": "GADAPTER...", - "amount": "1000", - "token_id": "1100_C..." - } - } - }' -``` +HOT completion endpoints live in the standalone `templar-hot-relayer` service under +`service/hot-relayer`. `funding-bridge` does not load HOT MPC config or expose `/relay/*`. ## Use Cases @@ -430,12 +374,6 @@ All CLI arguments can be set via environment variables: PORT=3000 NETWORK=mainnet BRIDGE_API_URL=https://bridge.chaindefuser.com/rpc -BRIDGE_RELAYER_BACKEND=none -# BRIDGE_RELAYER_BACKEND=hot -# HOT_MPC_API_URL=https://your-hot-mpc-api -# HOT_RELAYER_NEAR_RECEIVER=vault-counterparty.near -# HOT_RELAYER_STELLAR_RECEIVER=G... -# BRIDGE_RELAYER_AUTH_TOKEN=replace-with-long-random-token DRY_RUN=false # NEAR treasury diff --git a/service/funding-bridge/src/app.rs b/service/funding-bridge/src/app.rs index 5b8e4987c..40f80faf3 100644 --- a/service/funding-bridge/src/app.rs +++ b/service/funding-bridge/src/app.rs @@ -4,10 +4,8 @@ use std::sync::Arc; use crate::{ bridge::BridgeClient, - bridge_transport::{BridgeRelayer, HotBridgeRelayer}, config::Args, external::{config::EvmChainConfig, evm::EvmChainHandler, ExternalChainRegistry}, - hot_relayer::{HotMpcApiClient, HotRelayerRouting}, tokens::TokenRegistry, treasury::NearHandler, VERSION, @@ -28,12 +26,6 @@ pub struct App { /// External chain registry for cross-chain deposits pub external_chains: Arc, - /// Optional bridge relayer backend used for transport-specific completion flows. - pub bridge_relayer: Option>, - - /// Optional bearer token required for relay completion endpoints. - pub bridge_relayer_auth_token: Option, - /// Configuration pub config: Arc, @@ -77,102 +69,18 @@ impl App { // Initialize external chain registry let external_chains = Self::build_external_chain_registry(args); - let bridge_relayer = Self::build_bridge_relayer(); - let bridge_relayer_auth_token = Self::build_bridge_relayer_auth_token(); Self { near_handler, bridge_client, token_registry, external_chains, - bridge_relayer, - bridge_relayer_auth_token, config: Arc::new(args.clone()), dry_run: args.dry_run, version: VERSION, } } - /// Build optional bridge relayer backend from environment variables. - /// - /// `BRIDGE_RELAYER_BACKEND` options: - /// - `none` (default) - /// - `hot` - /// - /// For `hot`, these environment variables are required: - /// - `HOT_MPC_API_URL` - /// - `HOT_RELAYER_NEAR_RECEIVER` - /// - `HOT_RELAYER_STELLAR_RECEIVER` - fn build_bridge_relayer() -> Option> { - let backend = std::env::var("BRIDGE_RELAYER_BACKEND") - .unwrap_or_else(|_| "none".to_string()) - .to_lowercase(); - - match backend.as_str() { - "" | "none" => None, - "hot" => { - let mpc_api_url = match std::env::var("HOT_MPC_API_URL") { - Ok(v) if !v.trim().is_empty() => v, - _ => { - tracing::warn!( - "BRIDGE_RELAYER_BACKEND=hot but HOT_MPC_API_URL is missing; disabling bridge relayer backend" - ); - return None; - } - }; - let near_receiver = match std::env::var("HOT_RELAYER_NEAR_RECEIVER") { - Ok(v) if !v.trim().is_empty() => v, - _ => { - tracing::warn!( - "BRIDGE_RELAYER_BACKEND=hot but HOT_RELAYER_NEAR_RECEIVER is missing; disabling bridge relayer backend" - ); - return None; - } - }; - let stellar_receiver = match std::env::var("HOT_RELAYER_STELLAR_RECEIVER") { - Ok(v) if !v.trim().is_empty() => v, - _ => { - tracing::warn!( - "BRIDGE_RELAYER_BACKEND=hot but HOT_RELAYER_STELLAR_RECEIVER is missing; disabling bridge relayer backend" - ); - return None; - } - }; - - let routing = HotRelayerRouting { - near_receiver, - stellar_receiver, - }; - let signer = HotMpcApiClient::new(mpc_api_url); - let relayer = HotBridgeRelayer::new(routing, signer); - - tracing::info!(backend = "hot", "Configured bridge relayer backend"); - Some(Arc::new(relayer)) - } - other => { - tracing::warn!( - backend = %other, - "Unknown BRIDGE_RELAYER_BACKEND; disabling bridge relayer backend" - ); - None - } - } - } - - fn build_bridge_relayer_auth_token() -> Option { - match std::env::var("BRIDGE_RELAYER_AUTH_TOKEN") { - Ok(token) => { - let trimmed = token.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - } - Err(_) => None, - } - } - /// Build external chain registry from configuration fn build_external_chain_registry(args: &Args) -> Arc { let mut registry = ExternalChainRegistry::new(); diff --git a/service/funding-bridge/src/hot_relayer.rs b/service/funding-bridge/src/hot_relayer.rs deleted file mode 100644 index 8fc6d45fa..000000000 --- a/service/funding-bridge/src/hot_relayer.rs +++ /dev/null @@ -1,463 +0,0 @@ -//! HOT Bridge relayer primitives used to verify receiver-safety assumptions. -//! -//! This module focuses on one invariant: -//! - For withdrawals, relayer asks MPC to sign by `nonce` only, then uses -//! receiver data already committed on NEAR (`withdraw_data.receiver_id`). -//! - For deposits, relayer signs exactly the tuple extracted from the source -//! chain event, including `receiver_id`. - -use async_trait::async_trait; -use reqwest::StatusCode; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PendingWithdrawal { - pub nonce: String, - pub chain_id: u64, - pub withdraw_data: PendingWithdrawData, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PendingWithdrawData { - pub receiver_id: String, - pub amount: String, - pub token_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct StellarDepositEvent { - pub chain_id: u64, - pub nonce: String, - pub sender_id: String, - pub receiver_id: String, - pub token_id: String, - pub amount: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct DepositSignRequest { - pub chain: u64, - pub nonce: String, - pub sender_id: String, - pub receiver_id: String, - pub token_id: String, - pub amount: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub autopilot: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct StellarWithdrawExecution { - pub nonce: String, - pub token_id: String, - pub receiver: String, - pub amount: String, - pub signature: String, -} - -#[derive(Debug, thiserror::Error)] -pub enum HotRelayerError { - #[error("http request failed: {0}")] - Http(String), - #[error("mpc api returned status {status}: {body}")] - HttpStatus { status: u16, body: String }, - #[error("failed to decode mpc response: {0}")] - Decode(String), - #[error("unexpected {direction} receiver: expected {expected}, got {actual}")] - UnexpectedReceiver { - direction: &'static str, - expected: String, - actual: String, - }, -} - -#[derive(Debug, Clone, Deserialize)] -struct SignatureResponse { - signature: String, -} - -#[async_trait] -pub trait HotMpcSigner { - async fn withdraw_sign(&self, nonce: &str) -> Result; - async fn deposit_sign(&self, request: &DepositSignRequest) -> Result; -} - -#[derive(Debug, Clone)] -pub struct HotMpcApiClient { - client: reqwest::Client, - base_url: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HotRelayerRouting { - pub near_receiver: String, - pub stellar_receiver: String, -} - -impl HotRelayerRouting { - pub fn validate_deposit_event( - &self, - event: &StellarDepositEvent, - ) -> Result<(), HotRelayerError> { - if event.receiver_id != self.near_receiver { - return Err(HotRelayerError::UnexpectedReceiver { - direction: "deposit", - expected: self.near_receiver.clone(), - actual: event.receiver_id.clone(), - }); - } - Ok(()) - } - - pub fn validate_pending_withdrawal( - &self, - pending: &PendingWithdrawal, - ) -> Result<(), HotRelayerError> { - if pending.withdraw_data.receiver_id != self.stellar_receiver { - return Err(HotRelayerError::UnexpectedReceiver { - direction: "withdrawal", - expected: self.stellar_receiver.clone(), - actual: pending.withdraw_data.receiver_id.clone(), - }); - } - Ok(()) - } -} - -impl HotMpcApiClient { - #[must_use] - pub fn new(base_url: String) -> Self { - Self { - client: reqwest::Client::new(), - base_url, - } - } - - fn url(&self, path: &str) -> String { - format!( - "{}/{}", - self.base_url.trim_end_matches('/'), - path.trim_start_matches('/') - ) - } - - async fn sign( - &self, - path: &str, - body: &T, - ) -> Result { - let response = self - .client - .post(self.url(path)) - .json(body) - .send() - .await - .map_err(|e| HotRelayerError::Http(e.to_string()))?; - - let status = response.status(); - let bytes = response - .bytes() - .await - .map_err(|e| HotRelayerError::Http(e.to_string()))?; - let body_text = String::from_utf8_lossy(&bytes).to_string(); - - if status != StatusCode::OK { - return Err(HotRelayerError::HttpStatus { - status: status.as_u16(), - body: body_text, - }); - } - - let parsed: SignatureResponse = - serde_json::from_slice(&bytes).map_err(|e| HotRelayerError::Decode(e.to_string()))?; - Ok(parsed.signature) - } -} - -#[async_trait] -impl HotMpcSigner for HotMpcApiClient { - async fn withdraw_sign(&self, nonce: &str) -> Result { - #[derive(Serialize)] - struct WithdrawSignRequest<'a> { - nonce: &'a str, - } - - self.sign("/withdraw/sign", &WithdrawSignRequest { nonce }) - .await - } - - async fn deposit_sign(&self, request: &DepositSignRequest) -> Result { - self.sign("/deposit/sign", request).await - } -} - -#[must_use] -fn deposit_sign_request_from_event_unchecked(event: &StellarDepositEvent) -> DepositSignRequest { - DepositSignRequest { - chain: event.chain_id, - nonce: event.nonce.clone(), - sender_id: event.sender_id.clone(), - receiver_id: event.receiver_id.clone(), - token_id: event.token_id.clone(), - amount: event.amount.clone(), - autopilot: None, - } -} - -pub fn deposit_sign_request_from_event_checked( - event: &StellarDepositEvent, - routing: &HotRelayerRouting, -) -> Result { - routing.validate_deposit_event(event)?; - Ok(deposit_sign_request_from_event_unchecked(event)) -} - -#[must_use] -pub fn build_stellar_withdraw_execution( - pending: &PendingWithdrawal, - signature: String, -) -> StellarWithdrawExecution { - StellarWithdrawExecution { - nonce: pending.nonce.clone(), - token_id: pending.withdraw_data.token_id.clone(), - receiver: pending.withdraw_data.receiver_id.clone(), - amount: pending.withdraw_data.amount.clone(), - signature, - } -} - -async fn plan_stellar_withdraw_execution_unchecked( - signer: &S, - pending: &PendingWithdrawal, -) -> Result { - let signature = signer.withdraw_sign(&pending.nonce).await?; - Ok(build_stellar_withdraw_execution(pending, signature)) -} - -pub async fn plan_stellar_withdraw_execution_checked( - signer: &S, - pending: &PendingWithdrawal, - routing: &HotRelayerRouting, -) -> Result { - routing.validate_pending_withdrawal(pending)?; - plan_stellar_withdraw_execution_unchecked(signer, pending).await -} - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use serde_json::json; - use wiremock::{ - matchers::{body_json, method, path}, - Mock, MockServer, ResponseTemplate, - }; - - use super::*; - - #[derive(Default)] - struct RecordingSigner { - withdraw_nonces: Mutex>, - deposit_requests: Mutex>, - } - - #[async_trait] - impl HotMpcSigner for RecordingSigner { - async fn withdraw_sign(&self, nonce: &str) -> Result { - self.withdraw_nonces - .lock() - .unwrap_or_else(|e| panic!("{e}")) - .push(nonce.to_string()); - Ok("withdraw-signature".to_string()) - } - - async fn deposit_sign( - &self, - request: &DepositSignRequest, - ) -> Result { - self.deposit_requests - .lock() - .unwrap_or_else(|e| panic!("{e}")) - .push(request.clone()); - Ok("deposit-signature".to_string()) - } - } - - #[tokio::test] - async fn withdrawal_plan_uses_nonce_for_sign_and_committed_receiver_for_execution() { - let signer = RecordingSigner::default(); - let pending = PendingWithdrawal { - nonce: "991".to_string(), - chain_id: 1100, - withdraw_data: PendingWithdrawData { - receiver_id: "GADAPTERADDRESS".to_string(), - amount: "1500".to_string(), - token_id: "1100_CUSDC".to_string(), - }, - }; - - let routing = HotRelayerRouting { - near_receiver: "vault-counterparty.near".to_string(), - stellar_receiver: "GADAPTERADDRESS".to_string(), - }; - - let execution = plan_stellar_withdraw_execution_checked(&signer, &pending, &routing) - .await - .unwrap_or_else(|e| panic!("{e}")); - - assert_eq!(execution.nonce, "991"); - assert_eq!(execution.signature, "withdraw-signature"); - assert_eq!(execution.receiver, "GADAPTERADDRESS"); - assert_eq!(execution.token_id, "1100_CUSDC"); - assert_eq!(execution.amount, "1500"); - assert_eq!( - signer - .withdraw_nonces - .lock() - .unwrap_or_else(|e| panic!("{e}")) - .as_slice(), - &["991".to_string()] - ); - } - - #[test] - fn deposit_sign_request_copies_receiver_from_event() { - let event = StellarDepositEvent { - chain_id: 1100, - nonce: "21".to_string(), - sender_id: "GVAULT".to_string(), - receiver_id: "vault-counterparty.near".to_string(), - token_id: "1100_CUSDC".to_string(), - amount: "42".to_string(), - }; - let routing = HotRelayerRouting { - near_receiver: "vault-counterparty.near".to_string(), - stellar_receiver: "GADAPTERADDRESS".to_string(), - }; - - let request = deposit_sign_request_from_event_checked(&event, &routing) - .unwrap_or_else(|e| panic!("{e}")); - - assert_eq!(request.chain, 1100); - assert_eq!(request.nonce, "21"); - assert_eq!(request.sender_id, "GVAULT"); - assert_eq!(request.receiver_id, "vault-counterparty.near"); - assert_eq!(request.token_id, "1100_CUSDC"); - assert_eq!(request.amount, "42"); - assert_eq!(request.autopilot, None); - } - - #[test] - fn deposit_sign_request_checked_rejects_unexpected_receiver() { - let event = StellarDepositEvent { - chain_id: 1100, - nonce: "21".to_string(), - sender_id: "GVAULT".to_string(), - receiver_id: "wrong.near".to_string(), - token_id: "1100_CUSDC".to_string(), - amount: "42".to_string(), - }; - let routing = HotRelayerRouting { - near_receiver: "vault-counterparty.near".to_string(), - stellar_receiver: "GADAPTERADDRESS".to_string(), - }; - - let error = deposit_sign_request_from_event_checked(&event, &routing) - .expect_err("expected receiver mismatch"); - assert!(matches!( - error, - HotRelayerError::UnexpectedReceiver { - direction: "deposit", - .. - } - )); - } - - #[tokio::test] - async fn withdrawal_plan_checked_rejects_unexpected_receiver() { - let signer = RecordingSigner::default(); - let pending = PendingWithdrawal { - nonce: "991".to_string(), - chain_id: 1100, - withdraw_data: PendingWithdrawData { - receiver_id: "wrong-address".to_string(), - amount: "1500".to_string(), - token_id: "1100_CUSDC".to_string(), - }, - }; - let routing = HotRelayerRouting { - near_receiver: "vault-counterparty.near".to_string(), - stellar_receiver: "GADAPTERADDRESS".to_string(), - }; - - let error = plan_stellar_withdraw_execution_checked(&signer, &pending, &routing) - .await - .expect_err("expected receiver mismatch"); - assert!(matches!( - error, - HotRelayerError::UnexpectedReceiver { - direction: "withdrawal", - .. - } - )); - } - - #[tokio::test] - async fn mpc_client_posts_only_nonce_for_withdraw_sign() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/withdraw/sign")) - .and(body_json(json!({ "nonce": "4242" }))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "signature": "sig-1" - }))) - .mount(&server) - .await; - - let client = HotMpcApiClient::new(server.uri()); - let signature = client - .withdraw_sign("4242") - .await - .unwrap_or_else(|e| panic!("{e}")); - - assert_eq!(signature, "sig-1"); - } - - #[tokio::test] - async fn mpc_client_posts_deposit_sign_tuple_including_receiver() { - let server = MockServer::start().await; - let request = DepositSignRequest { - chain: 1100, - nonce: "12".to_string(), - sender_id: "GVAULT".to_string(), - receiver_id: "vault-counterparty.near".to_string(), - token_id: "1100_CUSDC".to_string(), - amount: "77".to_string(), - autopilot: None, - }; - - Mock::given(method("POST")) - .and(path("/deposit/sign")) - .and(body_json(json!({ - "chain": 1100, - "nonce": "12", - "sender_id": "GVAULT", - "receiver_id": "vault-counterparty.near", - "token_id": "1100_CUSDC", - "amount": "77" - }))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "signature": "sig-2" - }))) - .mount(&server) - .await; - - let client = HotMpcApiClient::new(server.uri()); - let signature = client - .deposit_sign(&request) - .await - .unwrap_or_else(|e| panic!("{e}")); - - assert_eq!(signature, "sig-2"); - } -} diff --git a/service/funding-bridge/src/lib.rs b/service/funding-bridge/src/lib.rs index a78c80fa5..bbaad31c4 100644 --- a/service/funding-bridge/src/lib.rs +++ b/service/funding-bridge/src/lib.rs @@ -47,11 +47,9 @@ pub mod app; pub mod bridge; -pub mod bridge_transport; pub mod config; pub mod error; pub mod external; -pub mod hot_relayer; pub mod intents; pub mod metrics; pub mod routes; diff --git a/service/funding-bridge/src/routes/health.rs b/service/funding-bridge/src/routes/health.rs index 400bc30c8..5541b58e2 100644 --- a/service/funding-bridge/src/routes/health.rs +++ b/service/funding-bridge/src/routes/health.rs @@ -151,8 +151,6 @@ mod tests { bridge_client, token_registry, external_chains: std::sync::Arc::new(crate::external::ExternalChainRegistry::new()), - bridge_relayer: None, - bridge_relayer_auth_token: None, config: Arc::new(args), dry_run: false, version: "0.1.0-test", diff --git a/service/funding-bridge/src/routes/mod.rs b/service/funding-bridge/src/routes/mod.rs index 7b22c59d5..d9ed0485b 100644 --- a/service/funding-bridge/src/routes/mod.rs +++ b/service/funding-bridge/src/routes/mod.rs @@ -4,7 +4,6 @@ pub mod deposit; pub mod health; pub mod metrics; pub mod models; -pub mod relay; #[cfg(test)] pub mod test_utils; pub mod tokens; @@ -22,14 +21,6 @@ pub fn create_router() -> Router { .route("/metrics", routing::get(metrics::metrics)) .route("/deposit", routing::post(deposit::deposit)) .route("/withdraw", routing::post(withdraw::withdraw)) - .route( - "/relay/deposit/complete", - routing::post(relay::complete_deposit), - ) - .route( - "/relay/withdrawal/complete", - routing::post(relay::complete_withdrawal), - ) .route("/tokens/lookup", routing::get(tokens::token_lookup)) } diff --git a/service/funding-bridge/src/routes/relay.rs b/service/funding-bridge/src/routes/relay.rs deleted file mode 100644 index 239a693e4..000000000 --- a/service/funding-bridge/src/routes/relay.rs +++ /dev/null @@ -1,234 +0,0 @@ -use axum::{ - extract::State, - http::{header::AUTHORIZATION, HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - Json, -}; -use serde::{Deserialize, Serialize}; - -use crate::{ - app::App, - hot_relayer::{ - HotRelayerError, PendingWithdrawal, StellarDepositEvent, StellarWithdrawExecution, - }, -}; - -#[derive(Debug, Clone, Deserialize)] -pub struct CompleteDepositRequest { - pub event: StellarDepositEvent, -} - -#[derive(Debug, Clone, Serialize)] -pub struct CompleteDepositResponse { - pub signature: String, - pub signed_receiver: String, - pub signed_nonce: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct CompleteWithdrawalRequest { - pub pending: PendingWithdrawal, -} - -#[derive(Debug, Clone, Serialize)] -pub struct CompleteWithdrawalResponse { - pub execution: StellarWithdrawExecution, -} - -#[tracing::instrument(name = "relay_complete_deposit", skip(app, req))] -pub async fn complete_deposit( - State(app): State, - headers: HeaderMap, - Json(req): Json, -) -> Response { - if let Some(response) = require_relay_auth(&app, &headers) { - return response; - } - - let Some(relayer) = app.bridge_relayer.as_ref() else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "error": "Bridge relayer backend is not configured" - })), - ) - .into_response(); - }; - - match relayer.complete_deposit(&req.event).await { - Ok(result) => ( - StatusCode::OK, - Json(CompleteDepositResponse { - signature: result.signature, - signed_receiver: result.sign_request.receiver_id, - signed_nonce: result.sign_request.nonce, - }), - ) - .into_response(), - Err(error) => map_relayer_error(error), - } -} - -#[tracing::instrument(name = "relay_complete_withdrawal", skip(app, req))] -pub async fn complete_withdrawal( - State(app): State, - headers: HeaderMap, - Json(req): Json, -) -> Response { - if let Some(response) = require_relay_auth(&app, &headers) { - return response; - } - - let Some(relayer) = app.bridge_relayer.as_ref() else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "error": "Bridge relayer backend is not configured" - })), - ) - .into_response(); - }; - - match relayer.complete_withdrawal(&req.pending).await { - Ok(execution) => ( - StatusCode::OK, - Json(CompleteWithdrawalResponse { execution }), - ) - .into_response(), - Err(error) => map_relayer_error(error), - } -} - -fn map_relayer_error(error: HotRelayerError) -> Response { - let status = match &error { - HotRelayerError::UnexpectedReceiver { .. } => StatusCode::BAD_REQUEST, - HotRelayerError::Http(_) - | HotRelayerError::HttpStatus { .. } - | HotRelayerError::Decode(_) => StatusCode::BAD_GATEWAY, - }; - - ( - status, - Json(serde_json::json!({ - "error": error.to_string(), - })), - ) - .into_response() -} - -fn require_relay_auth(app: &App, headers: &HeaderMap) -> Option { - let Some(expected_token) = app.bridge_relayer_auth_token.as_deref() else { - return None; - }; - - let expected_header = format!("Bearer {expected_token}"); - let is_authorized = headers - .get(AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .is_some_and(|actual| actual == expected_header); - - if is_authorized { - None - } else { - Some( - ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ - "error": "Unauthorized relay request" - })), - ) - .into_response(), - ) - } -} - -#[cfg(test)] -mod tests { - use axum::{ - extract::State, - http::{header::AUTHORIZATION, HeaderMap, HeaderValue, StatusCode}, - }; - - use super::*; - use crate::routes::test_utils::create_test_app; - - #[tokio::test] - async fn returns_503_when_backend_not_configured_deposit() { - let app = create_test_app(); - let req = CompleteDepositRequest { - event: StellarDepositEvent { - chain_id: 1100, - nonce: "1".to_string(), - sender_id: "G".to_string(), - receiver_id: "vault-counterparty.near".to_string(), - token_id: "1100_token".to_string(), - amount: "1".to_string(), - }, - }; - - let response = complete_deposit(State(app), HeaderMap::new(), Json(req)).await; - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - } - - #[tokio::test] - async fn returns_503_when_backend_not_configured_withdrawal() { - let app = create_test_app(); - let req = CompleteWithdrawalRequest { - pending: PendingWithdrawal { - nonce: "1".to_string(), - chain_id: 1100, - withdraw_data: crate::hot_relayer::PendingWithdrawData { - receiver_id: "GADAPTER".to_string(), - amount: "1".to_string(), - token_id: "1100_token".to_string(), - }, - }, - }; - - let response = complete_withdrawal(State(app), HeaderMap::new(), Json(req)).await; - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - } - - #[tokio::test] - async fn returns_401_when_auth_token_is_configured_and_missing_header() { - let mut app = create_test_app(); - app.bridge_relayer_auth_token = Some("relay-secret".to_string()); - let req = CompleteDepositRequest { - event: StellarDepositEvent { - chain_id: 1100, - nonce: "1".to_string(), - sender_id: "G".to_string(), - receiver_id: "vault-counterparty.near".to_string(), - token_id: "1100_token".to_string(), - amount: "1".to_string(), - }, - }; - - let response = complete_deposit(State(app), HeaderMap::new(), Json(req)).await; - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn returns_503_when_auth_token_is_configured_and_header_is_valid_but_backend_missing() { - let mut app = create_test_app(); - app.bridge_relayer_auth_token = Some("relay-secret".to_string()); - let req = CompleteDepositRequest { - event: StellarDepositEvent { - chain_id: 1100, - nonce: "1".to_string(), - sender_id: "G".to_string(), - receiver_id: "vault-counterparty.near".to_string(), - token_id: "1100_token".to_string(), - amount: "1".to_string(), - }, - }; - let mut headers = HeaderMap::new(); - headers.insert( - AUTHORIZATION, - HeaderValue::from_static("Bearer relay-secret"), - ); - - let response = complete_deposit(State(app), headers, Json(req)).await; - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - } -} diff --git a/service/funding-bridge/src/routes/test_utils.rs b/service/funding-bridge/src/routes/test_utils.rs index a2304df76..ed384ef4c 100644 --- a/service/funding-bridge/src/routes/test_utils.rs +++ b/service/funding-bridge/src/routes/test_utils.rs @@ -48,8 +48,6 @@ pub fn create_test_app() -> App { bridge_client, token_registry, external_chains: std::sync::Arc::new(crate::external::ExternalChainRegistry::new()), - bridge_relayer: None, - bridge_relayer_auth_token: None, config: Arc::new(args), dry_run: false, version: "0.1.0-test", diff --git a/service/funding-bridge/src/routes/tokens.rs b/service/funding-bridge/src/routes/tokens.rs index 66bae490e..7ed4df203 100644 --- a/service/funding-bridge/src/routes/tokens.rs +++ b/service/funding-bridge/src/routes/tokens.rs @@ -214,8 +214,6 @@ mod tests { bridge_client, token_registry, external_chains: std::sync::Arc::new(crate::external::ExternalChainRegistry::new()), - bridge_relayer: None, - bridge_relayer_auth_token: None, config: Arc::new(args), dry_run: false, version: "0.1.0-test", diff --git a/service/funding-bridge/src/routes/withdraw.rs b/service/funding-bridge/src/routes/withdraw.rs index 55f336e4a..5edd14507 100644 --- a/service/funding-bridge/src/routes/withdraw.rs +++ b/service/funding-bridge/src/routes/withdraw.rs @@ -469,8 +469,6 @@ mod tests { bridge_client, token_registry, external_chains: std::sync::Arc::new(crate::external::ExternalChainRegistry::new()), - bridge_relayer: None, - bridge_relayer_auth_token: None, config: Arc::new(args), dry_run: false, version: "0.1.0-test", diff --git a/service/funding-bridge/tests/tests.rs b/service/funding-bridge/tests/tests.rs index a73120138..adb60c5eb 100644 --- a/service/funding-bridge/tests/tests.rs +++ b/service/funding-bridge/tests/tests.rs @@ -264,8 +264,6 @@ async fn test_app_initialization() { external_chains: std::sync::Arc::new( templar_funding_bridge::external::ExternalChainRegistry::new(), ), - bridge_relayer: None, - bridge_relayer_auth_token: None, config: Arc::new(args), dry_run: false, version: "0.1.0-test", diff --git a/service/hot-relayer/.env.example b/service/hot-relayer/.env.example new file mode 100644 index 000000000..42a6de785 --- /dev/null +++ b/service/hot-relayer/.env.example @@ -0,0 +1,9 @@ +PORT=3001 +HOT_MPC_API_URL=https://your-hot-mpc-api +HOT_RELAYER_NEAR_RECEIVER=vault-counterparty.near +HOT_RELAYER_STELLAR_RECEIVER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +HOT_RELAYER_TOKEN_ID=1100_CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +HOT_RELAYER_CHAIN_ID=1100 +HOT_RELAYER_AUTH_TOKEN=replace-with-long-random-token +HOT_RELAYER_MPC_TIMEOUT_SECS=10 +HOT_RELAYER_MAX_REQUEST_BYTES=16384 diff --git a/service/hot-relayer/Cargo.toml b/service/hot-relayer/Cargo.toml new file mode 100644 index 000000000..a7ea0b653 --- /dev/null +++ b/service/hot-relayer/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "templar-hot-relayer" +edition.workspace = true +license.workspace = true +repository.workspace = true +version = "0.1.0" + +[[bin]] +name = "hot-relayer" +path = "src/main.rs" + +[dependencies] +async-trait = { workspace = true } +axum = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +getset = { workspace = true } +near-primitives = { workspace = true } +prometheus = "0.13" +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +stellar-xdr = { version = "26.0", default-features = false, features = ["curr", "std"] } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tower = { workspace = true, features = ["util"] } +tower-http = { workspace = true, features = ["trace"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } + +[dev-dependencies] +wiremock = { workspace = true } + +[lints] +workspace = true diff --git a/service/hot-relayer/Dockerfile b/service/hot-relayer/Dockerfile new file mode 100644 index 000000000..4e5304927 --- /dev/null +++ b/service/hot-relayer/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1 + +ARG RUST_VERSION=1.89 +FROM rust:${RUST_VERSION}-bookworm AS builder + +WORKDIR /workspace + +COPY . . + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/workspace/target \ + cargo build --locked --release -p templar-hot-relayer && \ + cp target/release/hot-relayer /tmp/hot-relayer + +FROM debian:bookworm-slim AS runtime + +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /tmp/hot-relayer /usr/local/bin/hot-relayer + +USER 10001:10001 +WORKDIR /tmp +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +ENV RUST_LOG=info,hot_relayer=debug,tower_http=info +EXPOSE 3001 +ENTRYPOINT ["/usr/local/bin/hot-relayer"] diff --git a/service/hot-relayer/README.md b/service/hot-relayer/README.md new file mode 100644 index 000000000..2762da0b8 --- /dev/null +++ b/service/hot-relayer/README.md @@ -0,0 +1,41 @@ +# HOT Relayer + +Narrow HOT bridge completion service. It exposes only: + +- `GET /health` +- `GET /metrics` +- `POST /relay/deposit/complete` +- `POST /relay/withdrawal/complete` + +The service requires bearer auth and validates HOT chain, token, receiver, nonce, and amount before +calling the configured HOT MPC API. It does not load treasury keys or initialize unrelated chain +handlers. + +## Configuration + +```bash +export PORT=3001 +export HOT_MPC_API_URL=https://your-hot-mpc-api +export HOT_RELAYER_NEAR_RECEIVER=vault-counterparty.near +export HOT_RELAYER_STELLAR_RECEIVER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +export HOT_RELAYER_TOKEN_ID=1100_CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +export HOT_RELAYER_CHAIN_ID=1100 +export HOT_RELAYER_AUTH_TOKEN=replace-with-long-random-token +export HOT_RELAYER_MPC_TIMEOUT_SECS=10 +export HOT_RELAYER_MAX_REQUEST_BYTES=16384 +``` + +Run: + +```bash +cargo run -p templar-hot-relayer +``` + +Build the production image from the contracts repository root: + +```bash +docker build -f service/hot-relayer/Dockerfile -t hot-relayer . +``` + +See [docs/operations.md](docs/operations.md) for deployment, monitoring, and incident response +notes. diff --git a/service/hot-relayer/docs/operations.md b/service/hot-relayer/docs/operations.md new file mode 100644 index 000000000..4317a803a --- /dev/null +++ b/service/hot-relayer/docs/operations.md @@ -0,0 +1,103 @@ +# HOT Relayer Operations + +## Deployment Checklist + +1. Confirm the counterparty vault, Stellar receiver, HOT chain id, and HOT token id with the + deployment record. +2. Generate a fresh `HOT_RELAYER_AUTH_TOKEN` and store it in the runtime secret manager. +3. Configure `HOT_MPC_API_URL` without embedding credentials in the URL when possible. +4. Set `HOT_RELAYER_MPC_TIMEOUT_SECS` lower than the upstream request timeout for the load balancer + or job runner. +5. Set `HOT_RELAYER_MAX_REQUEST_BYTES` to the smallest payload size that still covers expected HOT + completion requests. +6. Run the service in a network segment reachable only by the trusted caller that completes HOT + deposits or withdrawals. +7. Verify `/health` and `/metrics` before routing relay traffic. +8. Send a bad unauthenticated request to both relay endpoints and confirm HTTP 401. +9. Send an authenticated request with a wrong chain, token, or receiver and confirm HTTP 400 before + enabling production traffic. + +## Runtime Model + +The HOT relayer is intentionally narrow. It exposes only: + +- `GET /health` +- `GET /metrics` +- `POST /relay/deposit/complete` +- `POST /relay/withdrawal/complete` + +Relay routes require `Authorization: Bearer $HOT_RELAYER_AUTH_TOKEN`. The service validates the +configured HOT routing at startup and validates every request's chain id, token id, receiver, +nonce, and amount before it calls the HOT MPC API. It does not load treasury keys, local chain +signing keys, or unrelated EVM, Solana, Stellar, or NEAR treasury handlers. + +The service handles SIGINT and SIGTERM through graceful Axum shutdown. It has no local database or +run lock because each request is stateless after validation. Deploy multiple replicas only if the +HOT MPC backend and caller flow can tolerate duplicate requests. + +## Configuration + +Required: + +- `HOT_MPC_API_URL` +- `HOT_RELAYER_NEAR_RECEIVER` +- `HOT_RELAYER_STELLAR_RECEIVER` +- `HOT_RELAYER_TOKEN_ID` +- `HOT_RELAYER_AUTH_TOKEN` + +Optional: + +- `PORT`, default `3001` +- `HOT_RELAYER_CHAIN_ID`, default `1100` +- `HOT_RELAYER_MPC_TIMEOUT_SECS`, default `10` +- `HOT_RELAYER_MAX_REQUEST_BYTES`, default `16384` +- `RUST_LOG`, recommended `info,hot_relayer=debug,tower_http=info` + +Startup logs redact userinfo, path, query strings, and fragments from `HOT_MPC_API_URL`. + +## Build + +Build the runtime image from the contracts repository root: + +```sh +docker build -f service/hot-relayer/Dockerfile -t hot-relayer . +``` + +The Dockerfile uses `cargo build --locked`, so image builds fail if `Cargo.toml` and `Cargo.lock` +drift. + +For a direct local run: + +```sh +cargo run -p templar-hot-relayer +``` + +## Monitoring + +Alert on: + +- HTTP 401 spikes on relay endpoints, +- HTTP 400 spikes from invalid chain, token, receiver, nonce, or amount, +- HOT MPC HTTP failures or non-200 responses, +- request latency near `HOT_RELAYER_MPC_TIMEOUT_SECS`, +- `/health` failures, +- missing `/metrics` scrapes, +- unexpected configuration changes to receiver, token id, chain id, auth token, or MPC URL. + +Keep dependency-level `debug` or `trace` logging disabled when upstream URLs might contain +credentials. The service redacts URLs in its own startup logs, but lower-level HTTP libraries can +include raw transport details in verbose spans. + +## Incident Response + +If the auth token is suspected to be exposed: + +1. Remove external access to the service. +2. Rotate `HOT_RELAYER_AUTH_TOKEN`. +3. Restart every replica. +4. Confirm old-token requests return HTTP 401. +5. Re-enable trusted caller traffic. + +If the MPC API is returning invalid or unexpected signatures, stop relay traffic before restarting. +This service intentionally does not hold treasury authority, so containment should focus on route +access, MPC API access, and the counterparty contract state. diff --git a/service/funding-bridge/scripts/complete_hot_deposit.sh b/service/hot-relayer/scripts/complete_hot_deposit.sh similarity index 79% rename from service/funding-bridge/scripts/complete_hot_deposit.sh rename to service/hot-relayer/scripts/complete_hot_deposit.sh index c8dce9e8b..5538b1ae3 100755 --- a/service/funding-bridge/scripts/complete_hot_deposit.sh +++ b/service/hot-relayer/scripts/complete_hot_deposit.sh @@ -36,10 +36,11 @@ Arguments: receiver_id NEAR receiver account. Defaults to HOT_RELAYER_NEAR_RECEIVER from .env Environment: - SERVICE_URL Default: http://localhost:3000 - BRIDGE_RELAYER_AUTH_TOKEN Optional bearer token if relay auth is enabled + SERVICE_URL Default: http://localhost:3001 + HOT_RELAYER_AUTH_TOKEN Required bearer token HOT_RELAYER_NEAR_RECEIVER Required when receiver_id is not passed - CHAIN_ID Default: 1100 + HOT_RELAYER_TOKEN_ID Expected HOT token id configured in the service + HOT_RELAYER_CHAIN_ID Default: 1100 Example: ./scripts/complete_hot_deposit.sh \ @@ -74,18 +75,10 @@ require_health() { } build_auth_args() { - AUTH_ARGS=() - if [ -n "${BRIDGE_RELAYER_AUTH_TOKEN:-}" ]; then - AUTH_ARGS=(-H "Authorization: Bearer $BRIDGE_RELAYER_AUTH_TOKEN") - fi + AUTH_ARGS=(-H "Authorization: Bearer $HOT_RELAYER_AUTH_TOKEN") } check_required_config() { - if [ "${BRIDGE_RELAYER_BACKEND:-none}" != "hot" ]; then - print_error "BRIDGE_RELAYER_BACKEND must be set to 'hot' in .env" - exit 1 - fi - if [ -z "${HOT_MPC_API_URL:-}" ]; then print_error "HOT_MPC_API_URL is required in .env" exit 1 @@ -95,6 +88,16 @@ check_required_config() { print_error "HOT_RELAYER_STELLAR_RECEIVER is required in .env" exit 1 fi + + if [ -z "${HOT_RELAYER_TOKEN_ID:-}" ]; then + print_error "HOT_RELAYER_TOKEN_ID is required in .env" + exit 1 + fi + + if [ -z "${HOT_RELAYER_AUTH_TOKEN:-}" ]; then + print_error "HOT_RELAYER_AUTH_TOKEN is required in .env" + exit 1 + fi } NONCE="${1:-}" @@ -102,8 +105,8 @@ SENDER_ID="${2:-}" TOKEN_ID="${3:-}" AMOUNT="${4:-}" RECEIVER_ID="${5:-${HOT_RELAYER_NEAR_RECEIVER:-}}" -CHAIN_ID="${CHAIN_ID:-1100}" -SERVICE_URL="${SERVICE_URL:-http://localhost:3000}" +HOT_RELAYER_CHAIN_ID="${HOT_RELAYER_CHAIN_ID:-1100}" +SERVICE_URL="${SERVICE_URL:-http://localhost:3001}" if [ -z "$NONCE" ] || [ -z "$SENDER_ID" ] || [ -z "$TOKEN_ID" ] || [ -z "$AMOUNT" ]; then show_usage @@ -113,6 +116,7 @@ fi check_env_file load_env RECEIVER_ID="${5:-${HOT_RELAYER_NEAR_RECEIVER:-}}" +HOT_RELAYER_CHAIN_ID="${HOT_RELAYER_CHAIN_ID:-1100}" if [ -z "$RECEIVER_ID" ]; then print_error "receiver_id is required either as arg 5 or HOT_RELAYER_NEAR_RECEIVER in .env" @@ -127,7 +131,7 @@ JSON_PAYLOAD="$(python3 - < HotRelayerRouting { - HotRelayerRouting { - near_receiver: "vault-counterparty.near".to_string(), - stellar_receiver: "GADAPTERADDRESS".to_string(), - } + HotRelayerRouting::new( + "vault-counterparty.near".to_string(), + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + 1100, + "1100_CUSDC".to_string(), + ) + .unwrap_or_else(|e| panic!("{e}")) } #[tokio::test] @@ -111,7 +114,7 @@ mod tests { let event = StellarDepositEvent { chain_id: 1100, nonce: "55".to_string(), - sender_id: "GVAULT".to_string(), + sender_id: "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), receiver_id: "vault-counterparty.near".to_string(), token_id: "1100_CUSDC".to_string(), amount: "9".to_string(), @@ -136,7 +139,7 @@ mod tests { let event = StellarDepositEvent { chain_id: 1100, nonce: "55".to_string(), - sender_id: "GVAULT".to_string(), + sender_id: "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), receiver_id: "unexpected.near".to_string(), token_id: "1100_CUSDC".to_string(), amount: "9".to_string(), diff --git a/service/hot-relayer/src/config.rs b/service/hot-relayer/src/config.rs new file mode 100644 index 000000000..9610b902c --- /dev/null +++ b/service/hot-relayer/src/config.rs @@ -0,0 +1,344 @@ +use std::{ + num::{NonZeroU64, NonZeroUsize}, + time::Duration, +}; + +use clap::Parser; +use getset::{CopyGetters, Getters}; + +use crate::hot_relayer::{HotRelayerError, HotRelayerRouting, HOT_STELLAR_CHAIN_ID}; + +const DEFAULT_MPC_TIMEOUT_SECS: u64 = 10; +const DEFAULT_MAX_REQUEST_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, Parser)] +#[command(name = "hot-relayer")] +#[command(about = "Narrow HOT bridge completion relayer")] +#[command(version)] +pub struct Config { + #[arg(long, env = "PORT", default_value_t = 3001)] + pub port: u16, + + #[arg(long, env = "HOT_MPC_API_URL")] + pub hot_mpc_api_url: String, + + #[arg(long, env = "HOT_RELAYER_NEAR_RECEIVER")] + pub near_receiver: String, + + #[arg(long, env = "HOT_RELAYER_STELLAR_RECEIVER")] + pub stellar_receiver: String, + + #[arg(long, env = "HOT_RELAYER_TOKEN_ID")] + pub token_id: String, + + #[arg(long, env = "HOT_RELAYER_CHAIN_ID", default_value_t = HOT_STELLAR_CHAIN_ID)] + pub chain_id: u64, + + #[arg(long, env = "HOT_RELAYER_AUTH_TOKEN")] + pub auth_token: String, + + #[arg( + long, + env = "HOT_RELAYER_MPC_TIMEOUT_SECS", + default_value_t = DEFAULT_MPC_TIMEOUT_SECS + )] + pub mpc_timeout_secs: u64, + + #[arg( + long, + env = "HOT_RELAYER_MAX_REQUEST_BYTES", + default_value_t = DEFAULT_MAX_REQUEST_BYTES + )] + pub max_request_bytes: usize, +} + +impl Config { + pub fn validate(&self) -> Result { + let hot_mpc_api_url = HotMpcApiUrl::parse(&self.hot_mpc_api_url)?; + let auth_token = AuthToken::new(&self.auth_token)?; + let mpc_timeout = MpcTimeout::new(self.mpc_timeout_secs)?; + let max_request_bytes = MaxRequestBytes::new(self.max_request_bytes)?; + + let routing = HotRelayerRouting::new( + self.near_receiver.clone(), + self.stellar_receiver.clone(), + self.chain_id, + self.token_id.clone(), + ) + .map_err(ConfigError::Relayer)?; + + Ok(ValidatedConfig { + port: self.port, + hot_mpc_api_url, + routing, + auth_token, + mpc_timeout, + max_request_bytes, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MpcTimeout(NonZeroU64); + +impl MpcTimeout { + fn new(value: u64) -> Result { + NonZeroU64::new(value) + .map(Self) + .ok_or(ConfigError::ZeroMpcTimeout) + } + + #[must_use] + pub fn duration(self) -> Duration { + Duration::from_secs(self.0.get()) + } + + #[must_use] + pub fn seconds(self) -> u64 { + self.0.get() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaxRequestBytes(NonZeroUsize); + +impl MaxRequestBytes { + fn new(value: usize) -> Result { + NonZeroUsize::new(value) + .map(Self) + .ok_or(ConfigError::ZeroMaxRequestBytes) + } + + #[must_use] + pub fn get(self) -> usize { + self.0.get() + } +} + +#[derive(Debug, Clone, Getters, CopyGetters)] +pub struct ValidatedConfig { + #[get_copy = "pub"] + port: u16, + #[get = "pub"] + hot_mpc_api_url: HotMpcApiUrl, + #[get = "pub"] + routing: HotRelayerRouting, + #[get = "pub"] + auth_token: AuthToken, + #[get_copy = "pub"] + mpc_timeout: MpcTimeout, + #[get_copy = "pub"] + max_request_bytes: MaxRequestBytes, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HotMpcApiUrl(reqwest::Url); + +impl HotMpcApiUrl { + pub(crate) fn parse(value: &str) -> Result { + let trimmed = require_non_empty("HOT_MPC_API_URL", value)?; + let mut url = reqwest::Url::parse(trimmed).map_err(|error| ConfigError::InvalidUrl { + name: "HOT_MPC_API_URL", + reason: error.to_string(), + })?; + + match url.scheme() { + "http" | "https" => {} + scheme => { + return Err(ConfigError::InvalidUrl { + name: "HOT_MPC_API_URL", + reason: format!("unsupported scheme {scheme}"), + }); + } + } + + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + + Ok(Self(url)) + } + + #[must_use] + pub fn join(&self, path: &str) -> reqwest::Url { + self.0 + .join(path.trim_start_matches('/')) + .unwrap_or_else(|error| panic!("validated HOT MPC URL must join path: {error}")) + } + + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + #[must_use] + pub fn redacted(&self) -> reqwest::Url { + let mut url = self.0.clone(); + if !url.username().is_empty() { + let _ = url.set_username("redacted"); + } + if url.password().is_some() { + let _ = url.set_password(Some("redacted")); + } + if url.query().is_some() { + url.set_query(Some("redacted")); + } + if url.path() != "/" { + url.set_path("/redacted"); + } + url.set_fragment(None); + url + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthToken(String); + +impl AuthToken { + pub(crate) fn new(value: &str) -> Result { + let trimmed = require_non_empty("HOT_RELAYER_AUTH_TOKEN", value)?; + Ok(Self(trimmed.to_string())) + } + + #[must_use] + pub fn bearer_header(&self) -> String { + format!("Bearer {}", self.0) + } +} + +fn require_non_empty<'a>(name: &'static str, value: &'a str) -> Result<&'a str, ConfigError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + Err(ConfigError::Missing(name)) + } else { + Ok(trimmed) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("{0} is required")] + Missing(&'static str), + #[error("{name} is invalid: {reason}")] + InvalidUrl { name: &'static str, reason: String }, + #[error("HOT_RELAYER_MPC_TIMEOUT_SECS must be non-zero")] + ZeroMpcTimeout, + #[error("HOT_RELAYER_MAX_REQUEST_BYTES must be non-zero")] + ZeroMaxRequestBytes, + #[error("{0}")] + Relayer(#[from] HotRelayerError), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_config() -> Config { + Config { + port: 3001, + hot_mpc_api_url: "https://rpc1.hotdao.ai".to_string(), + near_receiver: "vault-counterparty.near".to_string(), + stellar_receiver: "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV" + .to_string(), + token_id: "1100_CUSDC".to_string(), + chain_id: HOT_STELLAR_CHAIN_ID, + auth_token: "relay-secret".to_string(), + mpc_timeout_secs: DEFAULT_MPC_TIMEOUT_SECS, + max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, + } + } + + #[test] + fn validate_builds_typed_config() { + let config = valid_config(); + let validated = config.validate().unwrap_or_else(|error| panic!("{error}")); + + assert_eq!(validated.port(), 3001); + assert_eq!( + validated.hot_mpc_api_url().join("/withdraw/sign").as_str(), + "https://rpc1.hotdao.ai/withdraw/sign" + ); + assert_eq!( + validated.auth_token().bearer_header(), + "Bearer relay-secret" + ); + assert_eq!(validated.routing().chain_id(), HOT_STELLAR_CHAIN_ID); + assert_eq!( + validated.mpc_timeout().duration(), + Duration::from_secs(DEFAULT_MPC_TIMEOUT_SECS) + ); + assert_eq!( + validated.max_request_bytes().get(), + DEFAULT_MAX_REQUEST_BYTES + ); + } + + #[test] + fn validate_redacts_mpc_url_for_logs() { + let mut config = valid_config(); + config.hot_mpc_api_url = "https://user:pass@example.com/private?token=secret".to_string(); + let validated = config.validate().unwrap_or_else(|error| panic!("{error}")); + + assert_eq!( + validated.hot_mpc_api_url().redacted().as_str(), + "https://redacted:redacted@example.com/redacted?redacted" + ); + } + + #[test] + fn validate_rejects_missing_auth() { + let mut config = valid_config(); + config.auth_token = " ".to_string(); + + assert!(matches!( + config.validate(), + Err(ConfigError::Missing("HOT_RELAYER_AUTH_TOKEN")) + )); + } + + #[test] + fn validate_rejects_invalid_mpc_url() { + let mut config = valid_config(); + config.hot_mpc_api_url = "not a url".to_string(); + + assert!(matches!( + config.validate(), + Err(ConfigError::InvalidUrl { + name: "HOT_MPC_API_URL", + .. + }) + )); + } + + #[test] + fn validate_rejects_invalid_routing() { + let mut config = valid_config(); + config.chain_id = 1101; + + assert!(matches!( + config.validate(), + Err(ConfigError::Relayer(HotRelayerError::InvalidRouting { + field: "chain_id", + .. + })) + )); + } + + #[test] + fn validate_rejects_zero_operational_limits() { + let mut config = valid_config(); + config.mpc_timeout_secs = 0; + assert!(matches!( + config.validate(), + Err(ConfigError::ZeroMpcTimeout) + )); + + let mut config = valid_config(); + config.max_request_bytes = 0; + assert!(matches!( + config.validate(), + Err(ConfigError::ZeroMaxRequestBytes) + )); + } +} diff --git a/service/hot-relayer/src/hot_relayer.rs b/service/hot-relayer/src/hot_relayer.rs new file mode 100644 index 000000000..f7dc6248a --- /dev/null +++ b/service/hot-relayer/src/hot_relayer.rs @@ -0,0 +1,850 @@ +//! HOT Bridge relayer primitives used to verify receiver-safety assumptions. +//! +//! This module focuses on one invariant: +//! - For withdrawals, relayer asks MPC to sign by `nonce` only, then uses +//! receiver data already committed on NEAR (`withdraw_data.receiver_id`). +//! - For deposits, relayer signs exactly the tuple extracted from the source +//! chain event, including `receiver_id`. + +use async_trait::async_trait; +use getset::{CopyGetters, Getters}; +use near_primitives::types::AccountId; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use std::{str::FromStr, time::Duration}; +use stellar_xdr::curr::ScAddress; + +use crate::config::HotMpcApiUrl; + +pub const HOT_STELLAR_CHAIN_ID: u64 = 1100; + +const MAX_HOT_NONCE_LEN: usize = 64; +const MAX_HOT_TOKEN_ID_LEN: usize = 256; +const MAX_HOT_AMOUNT_LEN: usize = 64; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingWithdrawal { + pub nonce: String, + pub chain_id: u64, + pub withdraw_data: PendingWithdrawData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingWithdrawData { + pub receiver_id: String, + pub amount: String, + pub token_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StellarDepositEvent { + pub chain_id: u64, + pub nonce: String, + pub sender_id: String, + pub receiver_id: String, + pub token_id: String, + pub amount: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DepositSignRequest { + pub chain: u64, + pub nonce: String, + pub sender_id: String, + pub receiver_id: String, + pub token_id: String, + pub amount: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub autopilot: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StellarWithdrawExecution { + pub nonce: String, + pub token_id: String, + pub receiver: String, + pub amount: String, + pub signature: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum HotRelayerError { + #[error("http request failed: {0}")] + Http(String), + #[error("mpc api returned status {status}: {body}")] + HttpStatus { status: u16, body: String }, + #[error("failed to decode mpc response: {0}")] + Decode(String), + #[error("unexpected {direction} receiver: expected {expected}, got {actual}")] + UnexpectedReceiver { + direction: &'static str, + expected: String, + actual: String, + }, + #[error("invalid {direction} {field}: {reason}")] + InvalidField { + direction: &'static str, + field: &'static str, + reason: String, + }, + #[error("invalid HOT relayer routing config {field}: {reason}")] + InvalidRouting { field: &'static str, reason: String }, +} + +#[derive(Debug, Clone, Deserialize)] +struct SignatureResponse { + signature: String, +} + +#[async_trait] +pub trait HotMpcSigner { + async fn withdraw_sign(&self, nonce: &str) -> Result; + async fn deposit_sign(&self, request: &DepositSignRequest) -> Result; +} + +#[derive(Debug, Clone)] +pub struct HotMpcApiClient { + client: reqwest::Client, + base_url: HotMpcApiUrl, +} + +#[derive(Debug, Clone, Getters, CopyGetters, PartialEq, Eq)] +pub struct HotRelayerRouting { + #[get = "pub"] + near_receiver: String, + #[get = "pub"] + stellar_receiver: String, + #[get_copy = "pub"] + chain_id: u64, + #[get = "pub"] + token_id: String, +} + +impl HotRelayerRouting { + pub fn new( + near_receiver: String, + stellar_receiver: String, + chain_id: u64, + token_id: String, + ) -> Result { + validate_near_receiver(&near_receiver).map_err(|reason| { + HotRelayerError::InvalidRouting { + field: "near_receiver", + reason, + } + })?; + validate_stellar_receiver(&stellar_receiver).map_err(|reason| { + HotRelayerError::InvalidRouting { + field: "stellar_receiver", + reason, + } + })?; + validate_chain_id(chain_id).map_err(|reason| HotRelayerError::InvalidRouting { + field: "chain_id", + reason, + })?; + validate_token_id(&token_id, chain_id).map_err(|reason| { + HotRelayerError::InvalidRouting { + field: "token_id", + reason, + } + })?; + + Ok(Self { + near_receiver, + stellar_receiver, + chain_id, + token_id, + }) + } + + pub fn validate_deposit_event( + &self, + event: &StellarDepositEvent, + ) -> Result<(), HotRelayerError> { + validate_chain_id_matches("deposit", event.chain_id, self.chain_id)?; + validate_nonce("deposit", &event.nonce)?; + validate_stellar_account("deposit", "sender_id", &event.sender_id)?; + validate_token_id_matches("deposit", &event.token_id, &self.token_id, self.chain_id)?; + validate_amount("deposit", &event.amount)?; + + if event.receiver_id != self.near_receiver { + return Err(HotRelayerError::UnexpectedReceiver { + direction: "deposit", + expected: self.near_receiver.clone(), + actual: event.receiver_id.clone(), + }); + } + validate_near_account("deposit", "receiver_id", &event.receiver_id)?; + Ok(()) + } + + pub fn validate_pending_withdrawal( + &self, + pending: &PendingWithdrawal, + ) -> Result<(), HotRelayerError> { + validate_chain_id_matches("withdrawal", pending.chain_id, self.chain_id)?; + validate_nonce("withdrawal", &pending.nonce)?; + validate_token_id_matches( + "withdrawal", + &pending.withdraw_data.token_id, + &self.token_id, + self.chain_id, + )?; + validate_amount("withdrawal", &pending.withdraw_data.amount)?; + + if pending.withdraw_data.receiver_id != self.stellar_receiver { + return Err(HotRelayerError::UnexpectedReceiver { + direction: "withdrawal", + expected: self.stellar_receiver.clone(), + actual: pending.withdraw_data.receiver_id.clone(), + }); + } + validate_stellar_account( + "withdrawal", + "receiver_id", + &pending.withdraw_data.receiver_id, + )?; + Ok(()) + } +} + +fn validate_chain_id(chain_id: u64) -> Result<(), String> { + if chain_id == HOT_STELLAR_CHAIN_ID { + Ok(()) + } else { + Err(format!( + "expected Stellar HOT chain id {HOT_STELLAR_CHAIN_ID}, got {chain_id}" + )) + } +} + +fn validate_chain_id_matches( + direction: &'static str, + actual: u64, + expected: u64, +) -> Result<(), HotRelayerError> { + if actual == expected { + Ok(()) + } else { + Err(HotRelayerError::InvalidField { + direction, + field: "chain_id", + reason: format!("expected {expected}, got {actual}"), + }) + } +} + +fn validate_nonce(direction: &'static str, nonce: &str) -> Result<(), HotRelayerError> { + if nonce.is_empty() { + return Err(HotRelayerError::InvalidField { + direction, + field: "nonce", + reason: "cannot be empty".to_string(), + }); + } + if nonce.len() > MAX_HOT_NONCE_LEN { + return Err(HotRelayerError::InvalidField { + direction, + field: "nonce", + reason: format!("too long, max {MAX_HOT_NONCE_LEN}"), + }); + } + if !nonce.bytes().all(|b| b.is_ascii_digit()) { + return Err(HotRelayerError::InvalidField { + direction, + field: "nonce", + reason: "must be decimal digits".to_string(), + }); + } + Ok(()) +} + +fn validate_token_id(token_id: &str, chain_id: u64) -> Result<(), String> { + if token_id.is_empty() { + return Err("cannot be empty".to_string()); + } + if token_id.len() > MAX_HOT_TOKEN_ID_LEN { + return Err(format!("too long, max {MAX_HOT_TOKEN_ID_LEN}")); + } + let expected_prefix = format!("{chain_id}_"); + if !token_id.starts_with(&expected_prefix) || token_id.len() == expected_prefix.len() { + return Err(format!( + "must start with {expected_prefix} and include an asset id" + )); + } + Ok(()) +} + +fn validate_token_id_matches( + direction: &'static str, + actual: &str, + expected: &str, + chain_id: u64, +) -> Result<(), HotRelayerError> { + validate_token_id(actual, chain_id).map_err(|reason| HotRelayerError::InvalidField { + direction, + field: "token_id", + reason, + })?; + if actual == expected { + Ok(()) + } else { + Err(HotRelayerError::InvalidField { + direction, + field: "token_id", + reason: format!("expected {expected}, got {actual}"), + }) + } +} + +fn validate_amount(direction: &'static str, amount: &str) -> Result<(), HotRelayerError> { + if amount.is_empty() { + return Err(HotRelayerError::InvalidField { + direction, + field: "amount", + reason: "cannot be empty".to_string(), + }); + } + if amount.len() > MAX_HOT_AMOUNT_LEN { + return Err(HotRelayerError::InvalidField { + direction, + field: "amount", + reason: format!("too long, max {MAX_HOT_AMOUNT_LEN}"), + }); + } + if !amount.bytes().all(|b| b.is_ascii_digit()) { + return Err(HotRelayerError::InvalidField { + direction, + field: "amount", + reason: "must be decimal digits".to_string(), + }); + } + let parsed = amount + .parse::() + .map_err(|e| HotRelayerError::InvalidField { + direction, + field: "amount", + reason: e.to_string(), + })?; + if parsed == 0 { + return Err(HotRelayerError::InvalidField { + direction, + field: "amount", + reason: "must be > 0".to_string(), + }); + } + Ok(()) +} + +fn validate_near_receiver(receiver: &str) -> Result<(), String> { + receiver + .parse::() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn validate_near_account( + direction: &'static str, + field: &'static str, + account_id: &str, +) -> Result<(), HotRelayerError> { + validate_near_receiver(account_id).map_err(|reason| HotRelayerError::InvalidField { + direction, + field, + reason, + }) +} + +fn validate_stellar_receiver(receiver: &str) -> Result<(), String> { + ScAddress::from_str(receiver) + .map(|_| ()) + .map_err(|_| "must be a valid Stellar account or contract address".to_string()) +} + +fn validate_stellar_account( + direction: &'static str, + field: &'static str, + account_id: &str, +) -> Result<(), HotRelayerError> { + validate_stellar_receiver(account_id).map_err(|reason| HotRelayerError::InvalidField { + direction, + field, + reason, + }) +} + +impl HotMpcApiClient { + pub fn new(base_url: HotMpcApiUrl, timeout: Duration) -> Result { + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(|error| HotRelayerError::Http(error.to_string()))?; + + Ok(Self { client, base_url }) + } + + #[must_use] + pub fn from_client(client: reqwest::Client, base_url: HotMpcApiUrl) -> Self { + Self { client, base_url } + } + + fn url(&self, path: &str) -> reqwest::Url { + self.base_url.join(path) + } + + async fn sign( + &self, + path: &str, + body: &T, + ) -> Result { + let response = self + .client + .post(self.url(path)) + .json(body) + .send() + .await + .map_err(|e| HotRelayerError::Http(e.to_string()))?; + + let status = response.status(); + let bytes = response + .bytes() + .await + .map_err(|e| HotRelayerError::Http(e.to_string()))?; + let body_text = String::from_utf8_lossy(&bytes).to_string(); + + if status != StatusCode::OK { + return Err(HotRelayerError::HttpStatus { + status: status.as_u16(), + body: body_text, + }); + } + + let parsed: SignatureResponse = + serde_json::from_slice(&bytes).map_err(|e| HotRelayerError::Decode(e.to_string()))?; + Ok(parsed.signature) + } +} + +#[async_trait] +impl HotMpcSigner for HotMpcApiClient { + async fn withdraw_sign(&self, nonce: &str) -> Result { + #[derive(Serialize)] + struct WithdrawSignRequest<'a> { + nonce: &'a str, + } + + self.sign("/withdraw/sign", &WithdrawSignRequest { nonce }) + .await + } + + async fn deposit_sign(&self, request: &DepositSignRequest) -> Result { + self.sign("/deposit/sign", request).await + } +} + +#[must_use] +fn deposit_sign_request_from_event_unchecked(event: &StellarDepositEvent) -> DepositSignRequest { + DepositSignRequest { + chain: event.chain_id, + nonce: event.nonce.clone(), + sender_id: event.sender_id.clone(), + receiver_id: event.receiver_id.clone(), + token_id: event.token_id.clone(), + amount: event.amount.clone(), + autopilot: None, + } +} + +pub fn deposit_sign_request_from_event_checked( + event: &StellarDepositEvent, + routing: &HotRelayerRouting, +) -> Result { + routing.validate_deposit_event(event)?; + Ok(deposit_sign_request_from_event_unchecked(event)) +} + +#[must_use] +pub fn build_stellar_withdraw_execution( + pending: &PendingWithdrawal, + signature: String, +) -> StellarWithdrawExecution { + StellarWithdrawExecution { + nonce: pending.nonce.clone(), + token_id: pending.withdraw_data.token_id.clone(), + receiver: pending.withdraw_data.receiver_id.clone(), + amount: pending.withdraw_data.amount.clone(), + signature, + } +} + +async fn plan_stellar_withdraw_execution_unchecked( + signer: &S, + pending: &PendingWithdrawal, +) -> Result { + let signature = signer.withdraw_sign(&pending.nonce).await?; + Ok(build_stellar_withdraw_execution(pending, signature)) +} + +pub async fn plan_stellar_withdraw_execution_checked( + signer: &S, + pending: &PendingWithdrawal, + routing: &HotRelayerRouting, +) -> Result { + routing.validate_pending_withdrawal(pending)?; + plan_stellar_withdraw_execution_unchecked(signer, pending).await +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use serde_json::json; + use wiremock::{ + matchers::{body_json, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + + #[derive(Default)] + struct RecordingSigner { + withdraw_nonces: Mutex>, + deposit_requests: Mutex>, + } + + #[async_trait] + impl HotMpcSigner for RecordingSigner { + async fn withdraw_sign(&self, nonce: &str) -> Result { + self.withdraw_nonces + .lock() + .unwrap_or_else(|e| panic!("{e}")) + .push(nonce.to_string()); + Ok("withdraw-signature".to_string()) + } + + async fn deposit_sign( + &self, + request: &DepositSignRequest, + ) -> Result { + self.deposit_requests + .lock() + .unwrap_or_else(|e| panic!("{e}")) + .push(request.clone()); + Ok("deposit-signature".to_string()) + } + } + + const STELLAR_ACCOUNT: &str = "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV"; + + fn routing() -> HotRelayerRouting { + HotRelayerRouting::new( + "vault-counterparty.near".to_string(), + STELLAR_ACCOUNT.to_string(), + HOT_STELLAR_CHAIN_ID, + "1100_CUSDC".to_string(), + ) + .unwrap_or_else(|e| panic!("{e}")) + } + + fn valid_deposit_event() -> StellarDepositEvent { + StellarDepositEvent { + chain_id: 1100, + nonce: "21".to_string(), + sender_id: STELLAR_ACCOUNT.to_string(), + receiver_id: "vault-counterparty.near".to_string(), + token_id: "1100_CUSDC".to_string(), + amount: "42".to_string(), + } + } + + fn valid_pending_withdrawal() -> PendingWithdrawal { + PendingWithdrawal { + nonce: "991".to_string(), + chain_id: 1100, + withdraw_data: PendingWithdrawData { + receiver_id: STELLAR_ACCOUNT.to_string(), + amount: "1500".to_string(), + token_id: "1100_CUSDC".to_string(), + }, + } + } + + #[tokio::test] + async fn withdrawal_plan_uses_nonce_for_sign_and_committed_receiver_for_execution() { + let signer = RecordingSigner::default(); + let pending = valid_pending_withdrawal(); + let routing = routing(); + + let execution = plan_stellar_withdraw_execution_checked(&signer, &pending, &routing) + .await + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(execution.nonce, "991"); + assert_eq!(execution.signature, "withdraw-signature"); + assert_eq!(execution.receiver, STELLAR_ACCOUNT); + assert_eq!(execution.token_id, "1100_CUSDC"); + assert_eq!(execution.amount, "1500"); + assert_eq!( + signer + .withdraw_nonces + .lock() + .unwrap_or_else(|e| panic!("{e}")) + .as_slice(), + &["991".to_string()] + ); + } + + #[test] + fn deposit_sign_request_copies_receiver_from_event() { + let event = valid_deposit_event(); + let routing = routing(); + + let request = deposit_sign_request_from_event_checked(&event, &routing) + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(request.chain, 1100); + assert_eq!(request.nonce, "21"); + assert_eq!(request.sender_id, STELLAR_ACCOUNT); + assert_eq!(request.receiver_id, "vault-counterparty.near"); + assert_eq!(request.token_id, "1100_CUSDC"); + assert_eq!(request.amount, "42"); + assert_eq!(request.autopilot, None); + } + + #[test] + fn deposit_sign_request_checked_rejects_unexpected_receiver() { + let mut event = valid_deposit_event(); + event.receiver_id = "wrong.near".to_string(); + let routing = routing(); + + let error = deposit_sign_request_from_event_checked(&event, &routing) + .expect_err("expected receiver mismatch"); + assert!(matches!( + error, + HotRelayerError::UnexpectedReceiver { + direction: "deposit", + .. + } + )); + } + + #[tokio::test] + async fn withdrawal_plan_checked_rejects_unexpected_receiver() { + let signer = RecordingSigner::default(); + let mut pending = valid_pending_withdrawal(); + pending.withdraw_data.receiver_id = "wrong-address".to_string(); + let routing = routing(); + + let error = plan_stellar_withdraw_execution_checked(&signer, &pending, &routing) + .await + .expect_err("expected receiver mismatch"); + assert!(matches!( + error, + HotRelayerError::UnexpectedReceiver { + direction: "withdrawal", + .. + } + )); + } + + #[test] + fn routing_config_rejects_invalid_values() { + let error = HotRelayerRouting::new( + "not a near account".to_string(), + STELLAR_ACCOUNT.to_string(), + HOT_STELLAR_CHAIN_ID, + "1100_CUSDC".to_string(), + ) + .expect_err("expected invalid NEAR receiver"); + assert!(matches!( + error, + HotRelayerError::InvalidRouting { + field: "near_receiver", + .. + } + )); + + let error = HotRelayerRouting::new( + "vault-counterparty.near".to_string(), + "not-stellar".to_string(), + HOT_STELLAR_CHAIN_ID, + "1100_CUSDC".to_string(), + ) + .expect_err("expected invalid Stellar receiver"); + assert!(matches!( + error, + HotRelayerError::InvalidRouting { + field: "stellar_receiver", + .. + } + )); + + let error = HotRelayerRouting::new( + "vault-counterparty.near".to_string(), + STELLAR_ACCOUNT.to_string(), + HOT_STELLAR_CHAIN_ID, + "1101_CUSDC".to_string(), + ) + .expect_err("expected token/chain mismatch"); + assert!(matches!( + error, + HotRelayerError::InvalidRouting { + field: "token_id", + .. + } + )); + } + + #[test] + fn deposit_validation_rejects_chain_token_amount_and_nonce_mismatches() { + let routing = routing(); + + let mut event = valid_deposit_event(); + event.chain_id = 1101; + assert!(matches!( + deposit_sign_request_from_event_checked(&event, &routing), + Err(HotRelayerError::InvalidField { + field: "chain_id", + .. + }) + )); + + let mut event = valid_deposit_event(); + event.token_id = "1100_OTHER".to_string(); + assert!(matches!( + deposit_sign_request_from_event_checked(&event, &routing), + Err(HotRelayerError::InvalidField { + field: "token_id", + .. + }) + )); + + let mut event = valid_deposit_event(); + event.amount = "0".to_string(); + assert!(matches!( + deposit_sign_request_from_event_checked(&event, &routing), + Err(HotRelayerError::InvalidField { + field: "amount", + .. + }) + )); + + let mut event = valid_deposit_event(); + event.nonce = "nonce-21".to_string(); + assert!(matches!( + deposit_sign_request_from_event_checked(&event, &routing), + Err(HotRelayerError::InvalidField { field: "nonce", .. }) + )); + } + + #[tokio::test] + async fn withdrawal_validation_rejects_chain_token_amount_and_nonce_mismatches() { + let signer = RecordingSigner::default(); + let routing = routing(); + + let mut pending = valid_pending_withdrawal(); + pending.chain_id = 1101; + assert!(matches!( + plan_stellar_withdraw_execution_checked(&signer, &pending, &routing).await, + Err(HotRelayerError::InvalidField { + field: "chain_id", + .. + }) + )); + + let mut pending = valid_pending_withdrawal(); + pending.withdraw_data.token_id = "1100_OTHER".to_string(); + assert!(matches!( + plan_stellar_withdraw_execution_checked(&signer, &pending, &routing).await, + Err(HotRelayerError::InvalidField { + field: "token_id", + .. + }) + )); + + let mut pending = valid_pending_withdrawal(); + pending.withdraw_data.amount = "1.5".to_string(); + assert!(matches!( + plan_stellar_withdraw_execution_checked(&signer, &pending, &routing).await, + Err(HotRelayerError::InvalidField { + field: "amount", + .. + }) + )); + + let mut pending = valid_pending_withdrawal(); + pending.nonce = "".to_string(); + assert!(matches!( + plan_stellar_withdraw_execution_checked(&signer, &pending, &routing).await, + Err(HotRelayerError::InvalidField { field: "nonce", .. }) + )); + } + + #[tokio::test] + async fn mpc_client_posts_only_nonce_for_withdraw_sign() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/withdraw/sign")) + .and(body_json(json!({ "nonce": "4242" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "signature": "sig-1" + }))) + .mount(&server) + .await; + + let client = HotMpcApiClient::new( + HotMpcApiUrl::parse(&server.uri()).unwrap_or_else(|e| panic!("{e}")), + Duration::from_secs(1), + ) + .unwrap_or_else(|e| panic!("{e}")); + let signature = client + .withdraw_sign("4242") + .await + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(signature, "sig-1"); + } + + #[tokio::test] + async fn mpc_client_posts_deposit_sign_tuple_including_receiver() { + let server = MockServer::start().await; + let request = DepositSignRequest { + chain: 1100, + nonce: "12".to_string(), + sender_id: "GVAULT".to_string(), + receiver_id: "vault-counterparty.near".to_string(), + token_id: "1100_CUSDC".to_string(), + amount: "77".to_string(), + autopilot: None, + }; + + Mock::given(method("POST")) + .and(path("/deposit/sign")) + .and(body_json(json!({ + "chain": 1100, + "nonce": "12", + "sender_id": "GVAULT", + "receiver_id": "vault-counterparty.near", + "token_id": "1100_CUSDC", + "amount": "77" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "signature": "sig-2" + }))) + .mount(&server) + .await; + + let client = HotMpcApiClient::new( + HotMpcApiUrl::parse(&server.uri()).unwrap_or_else(|e| panic!("{e}")), + Duration::from_secs(1), + ) + .unwrap_or_else(|e| panic!("{e}")); + let signature = client + .deposit_sign(&request) + .await + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(signature, "sig-2"); + } +} diff --git a/service/hot-relayer/src/lib.rs b/service/hot-relayer/src/lib.rs new file mode 100644 index 000000000..01653f902 --- /dev/null +++ b/service/hot-relayer/src/lib.rs @@ -0,0 +1,15 @@ +//! Standalone HOT bridge relayer service. +//! +//! This crate is intentionally narrow: it validates HOT deposit/withdrawal completion +//! payloads, requires bearer auth, and calls the configured HOT MPC API. It does not +//! load treasury keys or initialize unrelated chain handlers. + +pub mod bridge_transport; +pub mod config; +pub mod hot_relayer; +pub mod metrics; +pub mod routes; + +pub use config::Config; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/service/hot-relayer/src/main.rs b/service/hot-relayer/src/main.rs new file mode 100644 index 000000000..516142fb8 --- /dev/null +++ b/service/hot-relayer/src/main.rs @@ -0,0 +1,99 @@ +use std::net::SocketAddr; + +use axum::extract::DefaultBodyLimit; +use clap::Parser; +use templar_hot_relayer::{routes, Config, VERSION}; +use tower::ServiceBuilder; +use tower_http::trace::TraceLayer; +use tracing::{error, info}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "hot_relayer=debug,tower_http=debug,axum=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let cli_config = Config::parse(); + let config = match cli_config.validate() { + Ok(config) => config, + Err(error) => { + error!(%error, "invalid HOT relayer configuration"); + return Err(error.into()); + } + }; + let state = match routes::AppState::from_validated_config(config.clone()) { + Ok(state) => state, + Err(error) => { + error!(%error, "invalid HOT relayer configuration"); + return Err(error.into()); + } + }; + + info!( + version = VERSION, + port = config.port(), + chain_id = config.routing().chain_id(), + near_receiver = %config.routing().near_receiver(), + token_id = %config.routing().token_id(), + mpc_api_url = %config.hot_mpc_api_url().redacted(), + mpc_timeout_secs = config.mpc_timeout().seconds(), + max_request_bytes = config.max_request_bytes().get(), + "starting HOT relayer" + ); + + let router = routes::router(state).layer( + ServiceBuilder::new() + .layer(DefaultBodyLimit::max(config.max_request_bytes().get())) + .layer(TraceLayer::new_for_http()), + ); + let addr = SocketAddr::from(([0, 0, 0, 0], config.port())); + let listener = tokio::net::TcpListener::bind(addr).await?; + + info!(addr = %listener.local_addr()?, "listening for HOT relay requests"); + axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + + let mut terminate = match signal(SignalKind::terminate()) { + Ok(signal) => signal, + Err(error) => { + error!(%error, "failed to install SIGTERM handler"); + await_ctrl_c().await; + return; + } + }; + + tokio::select! { + () = await_ctrl_c() => {}, + _ = terminate.recv() => { + info!("SIGTERM received"); + }, + } + } + + #[cfg(not(unix))] + { + await_ctrl_c().await; + } + + info!("shutdown signal received"); +} + +async fn await_ctrl_c() { + if let Err(error) = tokio::signal::ctrl_c().await { + error!(%error, "failed to install Ctrl-C handler"); + } +} diff --git a/service/hot-relayer/src/metrics.rs b/service/hot-relayer/src/metrics.rs new file mode 100644 index 000000000..b0ef2921b --- /dev/null +++ b/service/hot-relayer/src/metrics.rs @@ -0,0 +1,21 @@ +use axum::response::{IntoResponse, Response}; +use prometheus::{Encoder, TextEncoder}; + +pub async fn metrics() -> Response { + let metric_families = prometheus::gather(); + let mut buffer = Vec::new(); + let encoder = TextEncoder::new(); + + match encoder.encode(&metric_families, &mut buffer) { + Ok(()) => ( + [(axum::http::header::CONTENT_TYPE, encoder.format_type())], + buffer, + ) + .into_response(), + Err(error) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to encode metrics: {error}"), + ) + .into_response(), + } +} diff --git a/service/hot-relayer/src/routes.rs b/service/hot-relayer/src/routes.rs new file mode 100644 index 000000000..eab387279 --- /dev/null +++ b/service/hot-relayer/src/routes.rs @@ -0,0 +1,305 @@ +use std::sync::Arc; + +use axum::{ + extract::State, + http::{header::AUTHORIZATION, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing, Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + bridge_transport::{BridgeRelayer, DepositCompletion, HotBridgeRelayer}, + config::{AuthToken, ValidatedConfig}, + hot_relayer::{ + HotMpcApiClient, HotRelayerError, PendingWithdrawal, StellarDepositEvent, + StellarWithdrawExecution, + }, + metrics, Config, VERSION, +}; + +#[derive(Clone)] +pub struct AppState { + relayer: Arc, + auth_token: AuthToken, +} + +impl AppState { + pub fn new(config: &Config) -> Result { + let validated = config + .validate() + .map_err(|error| HotRelayerError::InvalidRouting { + field: "config", + reason: error.to_string(), + })?; + Self::from_validated_config(validated) + } + + pub fn from_validated_config(config: ValidatedConfig) -> Result { + let signer = HotMpcApiClient::new( + config.hot_mpc_api_url().clone(), + config.mpc_timeout().duration(), + )?; + Ok(Self { + relayer: Arc::new(HotBridgeRelayer::new(config.routing().clone(), signer)), + auth_token: config.auth_token().clone(), + }) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CompleteDepositRequest { + pub event: StellarDepositEvent, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CompleteDepositResponse { + pub signature: String, + pub signed_receiver: String, + pub signed_nonce: String, +} + +impl From for CompleteDepositResponse { + fn from(value: DepositCompletion) -> Self { + Self { + signature: value.signature, + signed_receiver: value.sign_request.receiver_id, + signed_nonce: value.sign_request.nonce, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CompleteWithdrawalRequest { + pub pending: PendingWithdrawal, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CompleteWithdrawalResponse { + pub execution: StellarWithdrawExecution, +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/health", routing::get(health)) + .route("/metrics", routing::get(metrics::metrics)) + .route("/relay/deposit/complete", routing::post(complete_deposit)) + .route( + "/relay/withdrawal/complete", + routing::post(complete_withdrawal), + ) + .with_state(state) +} + +async fn health() -> Json { + Json(serde_json::json!({ + "healthy": true, + "service": "hot-relayer", + "version": VERSION, + })) +} + +#[tracing::instrument(name = "hot_relay_complete_deposit", skip(state, headers, req))] +async fn complete_deposit( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Response { + if let Some(response) = require_auth(&state, &headers) { + return response; + } + + match state.relayer.complete_deposit(&req.event).await { + Ok(result) => (StatusCode::OK, Json(CompleteDepositResponse::from(result))).into_response(), + Err(error) => map_relayer_error(error), + } +} + +#[tracing::instrument(name = "hot_relay_complete_withdrawal", skip(state, headers, req))] +async fn complete_withdrawal( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Response { + if let Some(response) = require_auth(&state, &headers) { + return response; + } + + match state.relayer.complete_withdrawal(&req.pending).await { + Ok(execution) => ( + StatusCode::OK, + Json(CompleteWithdrawalResponse { execution }), + ) + .into_response(), + Err(error) => map_relayer_error(error), + } +} + +fn require_auth(state: &AppState, headers: &HeaderMap) -> Option { + let expected_header = state.auth_token.bearer_header(); + let is_authorized = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|actual| actual == expected_header); + + if is_authorized { + None + } else { + Some( + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "Unauthorized relay request" + })), + ) + .into_response(), + ) + } +} + +fn map_relayer_error(error: HotRelayerError) -> Response { + let status = match &error { + HotRelayerError::UnexpectedReceiver { .. } + | HotRelayerError::InvalidField { .. } + | HotRelayerError::InvalidRouting { .. } => StatusCode::BAD_REQUEST, + HotRelayerError::Http(_) + | HotRelayerError::HttpStatus { .. } + | HotRelayerError::Decode(_) => StatusCode::BAD_GATEWAY, + }; + + ( + status, + Json(serde_json::json!({ + "error": error.to_string(), + })), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use axum::{ + body::{to_bytes, Body}, + http::{header::AUTHORIZATION, Request, StatusCode}, + }; + use tower::ServiceExt; + + use super::*; + use crate::{config::HotMpcApiUrl, hot_relayer::HotRelayerRouting}; + + fn state() -> AppState { + let routing = HotRelayerRouting::new( + "vault-counterparty.near".to_string(), + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + 1100, + "1100_CUSDC".to_string(), + ) + .unwrap_or_else(|e| panic!("{e}")); + let signer = HotMpcApiClient::new( + HotMpcApiUrl::parse("http://127.0.0.1:9").unwrap(), + std::time::Duration::from_secs(1), + ) + .unwrap(); + AppState { + relayer: Arc::new(HotBridgeRelayer::new(routing, signer)), + auth_token: AuthToken::new("relay-secret").unwrap(), + } + } + + #[tokio::test] + async fn health_route_is_public() { + let app = router(state()); + let response = app + .oneshot(Request::get("/health").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["service"], "hot-relayer"); + } + + #[tokio::test] + async fn metrics_route_is_public() { + let app = router(state()); + let response = app + .oneshot(Request::get("/metrics").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let content_type = response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + assert!(content_type.starts_with("text/plain")); + } + + #[tokio::test] + async fn funding_bridge_routes_are_not_exposed() { + let app = router(state()); + let response = app + .oneshot(Request::post("/withdraw").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn relay_routes_require_auth() { + let app = router(state()); + let response = app + .oneshot( + Request::post("/relay/deposit/complete") + .header("Content-Type", "application/json") + .body(Body::from( + r#"{"event":{"chain_id":1100,"nonce":"1","sender_id":"GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV","receiver_id":"vault-counterparty.near","token_id":"1100_CUSDC","amount":"1"}}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn withdrawal_relay_route_requires_auth() { + let app = router(state()); + let response = app + .oneshot( + Request::post("/relay/withdrawal/complete") + .header("Content-Type", "application/json") + .body(Body::from( + r#"{"pending":{"nonce":"1","chain_id":1100,"withdraw_data":{"receiver_id":"GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV","token_id":"1100_CUSDC","amount":"1"}}}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn authorized_invalid_payload_is_bad_request() { + let app = router(state()); + let response = app + .oneshot( + Request::post("/relay/deposit/complete") + .header(AUTHORIZATION, "Bearer relay-secret") + .header("Content-Type", "application/json") + .body(Body::from( + r#"{"event":{"chain_id":1101,"nonce":"1","sender_id":"GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV","receiver_id":"vault-counterparty.near","token_id":"1100_CUSDC","amount":"1"}}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } +} From 8df9bd5a9e5e6ff3836c65e12bd3c6a73040f1f8 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 3 Jun 2026 16:39:58 +0200 Subject: [PATCH 07/18] feat(soroban): add HOT bridge adapter --- Cargo.lock | 8 + Cargo.toml | 1 + contract/vault/justfile | 5 +- contract/vault/soroban/.env.example | 6 + contract/vault/soroban/README.md | 15 + .../soroban/hot-bridge-adapter/Cargo.toml | 22 + .../soroban/hot-bridge-adapter/src/lib.rs | 642 ++++++++++++++++++ contract/vault/soroban/justfile | 101 ++- 8 files changed, 796 insertions(+), 4 deletions(-) create mode 100644 contract/vault/soroban/hot-bridge-adapter/Cargo.toml create mode 100644 contract/vault/soroban/hot-bridge-adapter/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 114fac8bb..47ba1a502 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13125,6 +13125,14 @@ dependencies = [ "templar-vault-kernel", ] +[[package]] +name = "templar-soroban-hot-bridge-adapter" +version = "0.1.0" +dependencies = [ + "soroban-sdk", + "stellar-contract-utils", +] + [[package]] name = "templar-soroban-runtime" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d20a1c1d1..e1dc9068f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "contract/vault/soroban", "contract/vault/soroban/shared-types", "contract/vault/soroban/blend-adapter", + "contract/vault/soroban/hot-bridge-adapter", "contract/vault/soroban/governance", "contract/vault/soroban/share-token", "fuzz", diff --git a/contract/vault/justfile b/contract/vault/justfile index c861497b7..23da85391 100644 --- a/contract/vault/justfile +++ b/contract/vault/justfile @@ -101,7 +101,7 @@ soroban-share-metadata: # -------------------------------------------------------------------- # Count auditor-facing Rust LOC for vault runtime code only. -# Includes: src/ trees for curator-primitives, kernel, near, soroban, soroban-blend-adapter, soroban-governance, soroban-share-token. +# Includes: src/ trees for curator-primitives, kernel, near, soroban, soroban-blend-adapter, soroban-hot-bridge-adapter, soroban-governance, soroban-share-token. # Excludes: tests, proofs, test helpers, examples, snapshots. audit-loc: @if ! command -v tokei >/dev/null 2>&1; then echo "tokei not found"; exit 1; fi @@ -112,6 +112,7 @@ audit-loc: "{{vault_root}}/near/src" \ "{{vault_root}}/soroban/src" \ "{{vault_root}}/soroban/blend-adapter/src" \ + "{{vault_root}}/soroban/hot-bridge-adapter/src" \ "{{vault_root}}/soroban/governance/src" \ "{{vault_root}}/soroban/share-token/src" \ --types Rust \ @@ -128,7 +129,7 @@ audit-loc: audit-loc-per-crate: @if ! command -v tokei >/dev/null 2>&1; then echo "tokei not found"; exit 1; fi @if ! command -v jq >/dev/null 2>&1; then echo "jq not found"; exit 1; fi - @for crate in curator-primitives kernel near soroban soroban/blend-adapter soroban/governance soroban/share-token; do \ + @for crate in curator-primitives kernel near soroban soroban/blend-adapter soroban/hot-bridge-adapter soroban/governance soroban/share-token; do \ stats=$(tokei \ "{{vault_root}}/$crate/src" \ --types Rust \ diff --git a/contract/vault/soroban/.env.example b/contract/vault/soroban/.env.example index 38d3dd559..1fc20bef7 100644 --- a/contract/vault/soroban/.env.example +++ b/contract/vault/soroban/.env.example @@ -20,3 +20,9 @@ SOROBAN_SHARE_TOKEN=CA...REPLACE_ME BLEND_ADAPTER_ID=CA...REPLACE_ME BLEND_POOL_ID=CA...REPLACE_ME BLEND_FACTORY_ID=CA...REPLACE_ME + +# HOT bridge adapter config (optional) +HOT_BRIDGE_ADAPTER_ID=CA...REPLACE_ME +HOT_STELLAR_LOCKER_CONTRACT=CCLWL5NYSV2WJQ3VBU44AMDHEVKEPA45N2QP2LL62O3JVKPGWWAQUVAG +# Proven receiver bytes for the NEAR counterparty. Do not recompute blindly. +HOT_STELLAR_RECEIVER_HEX=52fd581de41f4bace88c936b89bf267a1161426a466adc518cd9e56f201651dd diff --git a/contract/vault/soroban/README.md b/contract/vault/soroban/README.md index 4de7c85e6..704c7855e 100644 --- a/contract/vault/soroban/README.md +++ b/contract/vault/soroban/README.md @@ -283,6 +283,21 @@ Use recipes in [contract/vault/soroban/justfile](./justfile): After deployment, register the adapter as a vault market before allocation. +## HOT Bridge Adapter + +HOT bridge integration lives in `contract/vault/soroban/hot-bridge-adapter`. It exposes the same +Soroban market-adapter surface as Blend (`supply`, `progress_withdrawal`, `total_assets`) while +routing supplied assets into the HOT Stellar locker. + +Use recipes in [contract/vault/soroban/justfile](./justfile): + +- `just build-hot-bridge-adapter` +- `HOT_STELLAR_LOCKER_CONTRACT= HOT_STELLAR_RECEIVER_HEX=<64 hex chars> just deploy-hot-bridge-adapter` +- `just hot-bridge-adapter-status` + +`HOT_STELLAR_RECEIVER_HEX` is intentionally explicit. It should be copied from a proven HOT receiver +for the NEAR counterparty, not recomputed locally during deployment. + ## Deployment Artifact The Soroban justfile builds two runtime artifacts: diff --git a/contract/vault/soroban/hot-bridge-adapter/Cargo.toml b/contract/vault/soroban/hot-bridge-adapter/Cargo.toml new file mode 100644 index 000000000..c7f7367ba --- /dev/null +++ b/contract/vault/soroban/hot-bridge-adapter/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "templar-soroban-hot-bridge-adapter" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Soroban market adapter contract for HOT bridge routing" + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" +doctest = false + +[features] +default = [] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = { version = "25.3.0", features = ["experimental_spec_shaking_v2", "hazmat-address"] } +stellar-contract-utils.workspace = true + +[dev-dependencies] +soroban-sdk = { version = "25.3.0", features = ["testutils", "experimental_spec_shaking_v2"] } diff --git a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs new file mode 100644 index 000000000..496911aa4 --- /dev/null +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -0,0 +1,642 @@ +#![no_std] + +use soroban_sdk::{ + address_payload::AddressPayload, + auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}, + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, + BytesN, Env, IntoVal, Symbol, Val, Vec, +}; +use stellar_contract_utils::upgradeable::{self, Upgradeable}; + +const INSTANCE_TTL_THRESHOLD: u32 = 518_400; +const INSTANCE_TTL_EXTEND_TO: u32 = 3_110_400; + +#[contracttype] +#[derive(Clone, Debug)] +enum DataKey { + Admin, + PendingAdmin, + Vault, + HotLocker, + HotReceiverId, + Paused, + Principal(Address), +} + +#[contracterror] +#[repr(u32)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum AdapterError { + Unauthorized = 1, + InvalidInput = 2, + MissingConfig = 3, + ArithmeticOverflow = 4, + ArithmeticUnderflow = 5, + InsufficientPrincipal = 6, + InsufficientReturnedBalance = 7, + Paused = 8, + InsufficientAdapterBalance = 9, + DepositPostconditionFailed = 10, +} + +#[contract] +pub struct HotBridgeAdapterContract; + +#[contractimpl] +impl HotBridgeAdapterContract { + pub fn __constructor( + env: Env, + admin: Address, + vault: Address, + hot_locker: Address, + hot_receiver_id: BytesN<32>, + ) -> Result<(), AdapterError> { + extend_instance_ttl(&env); + require_contract_address(&admin, AdapterError::InvalidInput)?; + require_contract_address(&vault, AdapterError::InvalidInput)?; + require_contract_address(&hot_locker, AdapterError::InvalidInput)?; + + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Vault, &vault); + env.storage() + .instance() + .set(&DataKey::HotLocker, &hot_locker); + env.storage() + .instance() + .set(&DataKey::HotReceiverId, &hot_receiver_id); + Ok(()) + } + + pub fn set_paused(env: Env, caller: Address, paused: bool) -> Result<(), AdapterError> { + extend_instance_ttl(&env); + require_admin_or_vault(&env, &caller)?; + env.storage().instance().set(&DataKey::Paused, &paused); + env.events() + .publish((symbol_short!("paused"), caller), paused); + Ok(()) + } + + pub fn paused(env: Env) -> bool { + extend_instance_ttl(&env); + is_paused(&env) + } + + pub fn supply( + env: Env, + caller: Address, + asset: Address, + amount: i128, + ) -> Result<(), AdapterError> { + extend_instance_ttl(&env); + require_not_paused(&env)?; + require_vault(&env, &caller)?; + require_positive_amount(amount)?; + + let adapter = env.current_contract_address(); + let hot_locker = get_hot_locker(&env)?; + let hot_receiver_id = get_hot_receiver_id(&env)?; + let client_timestamp = ledger_timestamp_ns(&env)?; + let amount_u128 = amount_as_u128(amount)?; + let token = soroban_sdk::token::Client::new(&env, &asset); + let balance_before = token.balance(&adapter); + if balance_before < amount { + return Err(AdapterError::InsufficientAdapterBalance); + } + + authorize_hot_deposit(&env, &hot_locker, &asset, &adapter, amount); + + let args: Vec = ( + adapter.clone(), + amount_u128, + asset.clone(), + hot_receiver_id, + client_timestamp, + ) + .into_val(&env); + env.invoke_contract::(&hot_locker, &Symbol::new(&env, "deposit"), args); + + let balance_after = token.balance(&adapter); + let spent = balance_before + .checked_sub(balance_after) + .ok_or(AdapterError::DepositPostconditionFailed)?; + if spent != amount { + return Err(AdapterError::DepositPostconditionFailed); + } + + increase_principal(&env, &asset, amount)?; + env.events() + .publish((symbol_short!("supply"), asset), amount); + Ok(()) + } + + pub fn withdraw( + env: Env, + caller: Address, + asset: Address, + amount: i128, + ) -> Result<(), AdapterError> { + Self::progress_withdrawal(env, caller, asset, amount).map(|_| ()) + } + + pub fn progress_withdrawal( + env: Env, + caller: Address, + asset: Address, + amount: i128, + ) -> Result { + extend_instance_ttl(&env); + require_not_paused(&env)?; + require_vault(&env, &caller)?; + require_positive_amount(amount)?; + + let principal = principal_of(&env, &asset); + if principal < amount { + return Err(AdapterError::InsufficientPrincipal); + } + + let adapter = env.current_contract_address(); + let vault = get_vault(&env)?; + let token = soroban_sdk::token::Client::new(&env, &asset); + if token.balance(&adapter) < amount { + return Err(AdapterError::InsufficientReturnedBalance); + } + + token.transfer(&adapter, &vault, &amount); + decrease_principal(&env, &asset, amount)?; + env.events() + .publish((symbol_short!("withdraw"), asset), amount); + Ok(amount) + } + + pub fn total_assets(env: Env, asset: Address) -> Result { + extend_instance_ttl(&env); + Ok(principal_of(&env, &asset)) + } + + pub fn principal(env: Env, asset: Address) -> i128 { + extend_instance_ttl(&env); + principal_of(&env, &asset) + } + + pub fn hot_receiver_id(env: Env) -> Result, AdapterError> { + extend_instance_ttl(&env); + get_hot_receiver_id(&env) + } + + pub fn hot_locker(env: Env) -> Result { + extend_instance_ttl(&env); + get_hot_locker(&env) + } + + pub fn admin(env: Env) -> Result { + extend_instance_ttl(&env); + get_admin(&env) + } + + pub fn vault(env: Env) -> Result { + extend_instance_ttl(&env); + get_vault(&env) + } + + pub fn propose_admin( + env: Env, + caller: Address, + new_admin: Address, + ) -> Result<(), AdapterError> { + extend_instance_ttl(&env); + require_admin(&env, &caller)?; + require_contract_address(&new_admin, AdapterError::InvalidInput)?; + env.storage() + .instance() + .set(&DataKey::PendingAdmin, &new_admin); + env.events() + .publish((symbol_short!("adm_prop"), caller), new_admin); + Ok(()) + } + + pub fn accept_admin(env: Env, caller: Address) -> Result<(), AdapterError> { + extend_instance_ttl(&env); + caller.require_auth(); + let pending: Address = env + .storage() + .instance() + .get(&DataKey::PendingAdmin) + .ok_or(AdapterError::MissingConfig)?; + if caller != pending { + return Err(AdapterError::Unauthorized); + } + env.storage().instance().set(&DataKey::Admin, &caller); + env.storage().instance().remove(&DataKey::PendingAdmin); + env.events().publish((symbol_short!("adm_acc"), caller), ()); + Ok(()) + } + + pub fn pending_admin(env: Env) -> Option
{ + extend_instance_ttl(&env); + env.storage().instance().get(&DataKey::PendingAdmin) + } + + pub fn rescue( + env: Env, + caller: Address, + asset: Address, + amount: i128, + receiver: Address, + ) -> Result<(), AdapterError> { + extend_instance_ttl(&env); + require_not_paused(&env)?; + require_vault(&env, &caller)?; + require_positive_amount(amount)?; + require_contract_address(&receiver, AdapterError::InvalidInput)?; + if receiver == env.current_contract_address() { + return Err(AdapterError::InvalidInput); + } + + let adapter = env.current_contract_address(); + soroban_sdk::token::Client::new(&env, &asset).transfer(&adapter, &receiver, &amount); + env.events() + .publish((symbol_short!("rescue"), asset, receiver), amount); + Ok(()) + } +} + +fn authorize_hot_deposit( + env: &Env, + hot_locker: &Address, + asset: &Address, + adapter: &Address, + amount: i128, +) { + env.authorize_as_current_contract(Vec::from_array( + env, + [InvokerContractAuthEntry::Contract(SubContractInvocation { + context: ContractContext { + contract: asset.clone(), + fn_name: Symbol::new(env, "transfer"), + args: (adapter.clone(), hot_locker.clone(), amount).into_val(env), + }, + sub_invocations: Vec::new(env), + })], + )); +} + +fn require_positive_amount(amount: i128) -> Result<(), AdapterError> { + if amount <= 0 { + return Err(AdapterError::InvalidInput); + } + Ok(()) +} + +fn amount_as_u128(amount: i128) -> Result { + u128::try_from(amount).map_err(|_| AdapterError::InvalidInput) +} + +fn ledger_timestamp_ns(env: &Env) -> Result { + u128::from(env.ledger().timestamp()) + .checked_mul(1_000_000_000) + .ok_or(AdapterError::ArithmeticOverflow) +} + +fn principal_of(env: &Env, asset: &Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Principal(asset.clone())) + .unwrap_or(0) +} + +fn increase_principal(env: &Env, asset: &Address, amount: i128) -> Result<(), AdapterError> { + let updated = principal_of(env, asset) + .checked_add(amount) + .ok_or(AdapterError::ArithmeticOverflow)?; + env.storage() + .instance() + .set(&DataKey::Principal(asset.clone()), &updated); + Ok(()) +} + +fn decrease_principal(env: &Env, asset: &Address, amount: i128) -> Result<(), AdapterError> { + let principal = principal_of(env, asset); + if principal < amount { + return Err(AdapterError::InsufficientPrincipal); + } + let updated = principal + .checked_sub(amount) + .ok_or(AdapterError::ArithmeticUnderflow)?; + let key = DataKey::Principal(asset.clone()); + if updated == 0 { + env.storage().instance().remove(&key); + } else { + env.storage().instance().set(&key, &updated); + } + Ok(()) +} + +fn get_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(AdapterError::MissingConfig) +} + +fn get_vault(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Vault) + .ok_or(AdapterError::MissingConfig) +} + +fn get_hot_locker(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::HotLocker) + .ok_or(AdapterError::MissingConfig) +} + +fn get_hot_receiver_id(env: &Env) -> Result, AdapterError> { + env.storage() + .instance() + .get(&DataKey::HotReceiverId) + .ok_or(AdapterError::MissingConfig) +} + +fn require_admin(env: &Env, caller: &Address) -> Result<(), AdapterError> { + caller.require_auth(); + if caller != &get_admin(env)? { + return Err(AdapterError::Unauthorized); + } + Ok(()) +} + +fn require_vault(env: &Env, caller: &Address) -> Result<(), AdapterError> { + caller.require_auth(); + if caller != &get_vault(env)? { + return Err(AdapterError::Unauthorized); + } + Ok(()) +} + +fn require_admin_or_vault(env: &Env, caller: &Address) -> Result<(), AdapterError> { + caller.require_auth(); + let admin = get_admin(env)?; + let vault = get_vault(env)?; + if caller != &admin && caller != &vault { + return Err(AdapterError::Unauthorized); + } + Ok(()) +} + +fn is_paused(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) +} + +fn require_not_paused(env: &Env) -> Result<(), AdapterError> { + if is_paused(env) { + return Err(AdapterError::Paused); + } + Ok(()) +} + +fn require_contract_address(addr: &Address, err: AdapterError) -> Result<(), AdapterError> { + if is_contract_address(addr) { + Ok(()) + } else { + Err(err) + } +} + +fn is_contract_address(addr: &Address) -> bool { + matches!( + AddressPayload::from_address(addr), + Some(AddressPayload::ContractIdHash(_)) + ) +} + +impl Upgradeable for HotBridgeAdapterContract { + #[allow(deprecated)] + fn upgrade(e: &Env, new_wasm_hash: BytesN<32>, operator: Address) { + extend_instance_ttl(e); + require_admin(e, &operator).unwrap_or_else(|err| panic_with_error!(e, err)); + upgradeable::upgrade(e, &new_wasm_hash); + e.events() + .publish((symbol_short!("upgrade"), operator), new_wasm_hash); + } +} + +fn extend_instance_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger as _}, + token::StellarAssetClient, + }; + + #[contract] + struct DummyContract; + + #[contractimpl] + impl DummyContract {} + + #[contract] + struct MockHotLocker; + + #[contractimpl] + impl MockHotLocker { + pub fn deposit( + env: Env, + sender_id: Address, + amount: u128, + token: Address, + receiver_id: BytesN<32>, + client_timestamp: u128, + ) -> u128 { + let amount_i128 = i128::try_from(amount).unwrap(); + env.storage() + .instance() + .set(&Symbol::new(&env, "sender"), &sender_id); + env.storage() + .instance() + .set(&Symbol::new(&env, "receiver"), &receiver_id); + env.storage() + .instance() + .set(&Symbol::new(&env, "token"), &token); + env.storage() + .instance() + .set(&Symbol::new(&env, "timestamp"), &client_timestamp); + env.storage() + .instance() + .set(&Symbol::new(&env, "amount"), &amount_i128); + soroban_sdk::token::Client::new(&env, &token).transfer( + &sender_id, + &env.current_contract_address(), + &amount_i128, + ); + client_timestamp + } + + pub fn amount(env: Env) -> i128 { + env.storage() + .instance() + .get(&Symbol::new(&env, "amount")) + .unwrap() + } + + pub fn timestamp(env: Env) -> u128 { + env.storage() + .instance() + .get(&Symbol::new(&env, "timestamp")) + .unwrap() + } + + pub fn receiver(env: Env) -> BytesN<32> { + env.storage() + .instance() + .get(&Symbol::new(&env, "receiver")) + .unwrap() + } + } + + #[contract] + struct MockNoTransferHotLocker; + + #[contractimpl] + impl MockNoTransferHotLocker { + pub fn deposit( + _env: Env, + _sender_id: Address, + _amount: u128, + _token: Address, + _receiver_id: BytesN<32>, + client_timestamp: u128, + ) -> u128 { + client_timestamp + } + } + + fn register_dummy_contract(env: &Env) -> Address { + env.register(DummyContract, ()) + } + + fn setup(env: &Env) -> (Address, Address, Address, Address, Address, BytesN<32>) { + env.mock_all_auths(); + env.ledger().set_timestamp(1_777_000_000); + let admin = register_dummy_contract(env); + let vault = register_dummy_contract(env); + let hot_locker = env.register(MockHotLocker, ()); + let asset_sac = env.register_stellar_asset_contract_v2(Address::generate(env)); + let asset = asset_sac.address(); + let receiver = BytesN::from_array( + env, + &[ + 0x52, 0xfd, 0x58, 0x1d, 0xe4, 0x1f, 0x4b, 0xac, 0xe8, 0x8c, 0x93, 0x6b, 0x89, 0xbf, + 0x26, 0x7a, 0x11, 0x61, 0x42, 0x6a, 0x46, 0x6a, 0xdc, 0x51, 0x8c, 0xd9, 0xe5, 0x6f, + 0x20, 0x16, 0x51, 0xdd, + ], + ); + let adapter = env.register( + HotBridgeAdapterContract, + (&admin, &vault, &hot_locker, &receiver), + ); + (adapter, admin, vault, hot_locker, asset, receiver) + } + + #[test] + fn supply_calls_hot_locker_with_configured_receiver_id() { + let env = Env::default(); + let (adapter, _admin, vault, hot_locker, asset, receiver) = setup(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let asset_admin = StellarAssetClient::new(&env, &asset); + asset_admin.mint(&adapter, &100); + + client.supply(&vault, &asset, &100); + + let locker_client = MockHotLockerClient::new(&env, &hot_locker); + assert_eq!(locker_client.receiver(), receiver); + assert_eq!(locker_client.amount(), 100); + assert_eq!(locker_client.timestamp(), 1_777_000_000_000_000_000); + assert_eq!(client.total_assets(&asset), 100); + assert_eq!( + soroban_sdk::token::Client::new(&env, &asset).balance(&adapter), + 0 + ); + assert_eq!( + soroban_sdk::token::Client::new(&env, &asset).balance(&hot_locker), + 100 + ); + } + + #[test] + fn progress_withdrawal_returns_hot_completed_balance_to_vault() { + let env = Env::default(); + let (adapter, _admin, vault, _hot_locker, asset, _receiver) = setup(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let asset_admin = StellarAssetClient::new(&env, &asset); + asset_admin.mint(&adapter, &100); + client.supply(&vault, &asset, &100); + + asset_admin.mint(&adapter, &40); + + assert_eq!(client.progress_withdrawal(&vault, &asset, &40), 40); + assert_eq!(client.total_assets(&asset), 60); + assert_eq!( + soroban_sdk::token::Client::new(&env, &asset).balance(&vault), + 40 + ); + } + + #[test] + fn progress_withdrawal_requires_returned_balance() { + let env = Env::default(); + let (adapter, _admin, vault, _hot_locker, asset, _receiver) = setup(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let asset_admin = StellarAssetClient::new(&env, &asset); + asset_admin.mint(&adapter, &100); + client.supply(&vault, &asset, &100); + + let error = client.try_progress_withdrawal(&vault, &asset, &1); + assert_eq!(error, Err(Ok(AdapterError::InsufficientReturnedBalance))); + } + + #[test] + fn supply_rejects_locker_that_does_not_pull_exact_amount() { + let env = Env::default(); + env.mock_all_auths(); + let admin = register_dummy_contract(&env); + let vault = register_dummy_contract(&env); + let hot_locker = env.register(MockNoTransferHotLocker, ()); + let asset_sac = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let asset = asset_sac.address(); + let receiver = BytesN::from_array(&env, &[7; 32]); + let adapter = env.register( + HotBridgeAdapterContract, + (&admin, &vault, &hot_locker, &receiver), + ); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + StellarAssetClient::new(&env, &asset).mint(&adapter, &100); + + let error = client.try_supply(&vault, &asset, &100); + + assert_eq!(error, Err(Ok(AdapterError::DepositPostconditionFailed))); + assert_eq!(client.total_assets(&asset), 0); + } + + #[test] + fn non_vault_cannot_supply() { + let env = Env::default(); + let (adapter, _admin, _vault, _hot_locker, asset, _receiver) = setup(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let caller = register_dummy_contract(&env); + + let error = client.try_supply(&caller, &asset, &1); + assert_eq!(error, Err(Ok(AdapterError::Unauthorized))); + } +} diff --git a/contract/vault/soroban/justfile b/contract/vault/soroban/justfile index 7750e2f0c..16d7daef9 100644 --- a/contract/vault/soroban/justfile +++ b/contract/vault/soroban/justfile @@ -34,6 +34,9 @@ set dotenv-load := false # SOROBAN_VIRTUAL_ASSETS Initial virtual assets offset (default: 0) # BLEND_ADAPTER_ID Deployed Blend adapter contract ID # BLEND_POOL_ID Blend pool address +# HOT_BRIDGE_ADAPTER_ID Deployed HOT bridge adapter contract ID +# HOT_STELLAR_LOCKER_CONTRACT HOT Stellar locker contract ID +# HOT_STELLAR_RECEIVER_HEX Proven HOT receiver bytes for the NEAR counterparty # SOROBAN_ADAPTER_ADMIN Blend adapter admin contract (default: governance, then vault) # # ============================================================================= @@ -48,6 +51,7 @@ remap_rustflags := "--remap-path-prefix " + root + "=/workspace --remap-path-pre soroban_rust_toolchain := env_var_or_default("SOROBAN_RUST_TOOLCHAIN", "1.89.0") default_wasm := optimized_wasm blend_adapter_wasm := wasm_dir + "/templar_soroban_blend_adapter.wasm" +hot_bridge_adapter_wasm := wasm_dir + "/templar_soroban_hot_bridge_adapter.wasm" governance_wasm := wasm_dir + "/templar_soroban_governance.wasm" share_token_wasm := wasm_dir + "/templar_soroban_share_token.wasm" payload_script := justfile_directory() + "/scripts/vault_payload.py" @@ -134,6 +138,12 @@ build-blend-adapter: @just -f "{{justfile_directory()}}/justfile" _optimize-wasm templar-soroban-blend-adapter "{{wasm_dir}}/templar_soroban_blend_adapter.wasm" @echo "✓ Built: {{blend_adapter_wasm}}" +# Build and optimize the HOT bridge adapter WASM. +build-hot-bridge-adapter: + @echo "Building HOT bridge adapter WASM..." + @just -f "{{justfile_directory()}}/justfile" _optimize-wasm templar-soroban-hot-bridge-adapter "{{wasm_dir}}/templar_soroban_hot_bridge_adapter.wasm" + @echo "✓ Built: {{hot_bridge_adapter_wasm}}" + # Check the Soroban ERC-4626 proxy crate. check-4626-proxy: @echo "Checking Soroban ERC-4626 proxy crate..." @@ -143,8 +153,8 @@ check-4626-proxy: -p templar-4626-proxy-soroban @echo "✓ Checked: templar-4626-proxy-soroban" -# Build all WASMs (vault + governance + share token + blend adapter). -build-all: build build-blend-adapter +# Build all WASMs (vault + governance + share token + adapters). +build-all: build build-blend-adapter build-hot-bridge-adapter @echo "✓ All contracts built" # Build debug WASM, emit twiggy reports, and print them. @@ -539,6 +549,87 @@ deploy-blend-adapter pool_address: echo "$adapter_id" > "{{state_dir}}/blend_adapter_id"; \ echo "{{pool_address}}" > "{{state_dir}}/blend_pool_id" +# ============================================================================= +# HOT BRIDGE ADAPTER DEPLOYMENT +# ============================================================================= +# +# The HOT bridge adapter presents Stellar -> HOT -> NEAR routing as a Soroban +# vault market adapter. HOT_STELLAR_RECEIVER_HEX must be copied from a +# proven-good HOT receiver for the NEAR counterparty; do not recompute it here. +# +# ============================================================================= + +# Deploy the HOT bridge adapter contract. +deploy-hot-bridge-adapter: + @wasm="${HOT_BRIDGE_ADAPTER_WASM:-{{hot_bridge_adapter_wasm}}}"; \ + if [ ! -f "$wasm" ]; then \ + echo "Error: HOT bridge adapter WASM not found at $wasm"; \ + echo "Run: just build-hot-bridge-adapter"; \ + exit 1; \ + fi; \ + vault_id="${SOROBAN_CONTRACT_ID:-$(cat {{state_dir}}/vault_id 2>/dev/null)}"; \ + if [ -z "$vault_id" ]; then \ + echo "Error: Vault not deployed. Run 'just deploy' first."; \ + exit 1; \ + fi; \ + admin="${SOROBAN_ADAPTER_ADMIN:-$(cat {{state_dir}}/governance_id 2>/dev/null)}"; \ + if [ -z "$admin" ]; then \ + admin="$vault_id"; \ + fi; \ + hot_locker="${HOT_STELLAR_LOCKER_CONTRACT:?set HOT_STELLAR_LOCKER_CONTRACT}"; \ + hot_receiver_id="${HOT_STELLAR_RECEIVER_HEX:?set HOT_STELLAR_RECEIVER_HEX to the proven HOT receiver bytes}"; \ + if ! printf '%s' "$hot_receiver_id" | grep -Eq '^[0-9A-Fa-f]{64}$'; then \ + echo "Error: HOT_STELLAR_RECEIVER_HEX must be exactly 64 hex characters"; \ + exit 1; \ + fi; \ + network="${SOROBAN_NETWORK:-{{default_network}}}"; \ + source="${SOROBAN_IDENTITY:-{{default_identity}}}"; \ + echo "Deploying HOT bridge adapter..."; \ + echo " Admin: $admin"; \ + echo " Vault: $vault_id"; \ + echo " HOT locker: $hot_locker"; \ + echo " HOT receiver: $hot_receiver_id"; \ + adapter_id=$(stellar contract deploy \ + --wasm "$wasm" \ + --network "$network" \ + --source-account "$source" \ + -- \ + --admin "$admin" \ + --vault "$vault_id" \ + --hot-locker "$hot_locker" \ + --hot-receiver-id "$hot_receiver_id"); \ + if [ -z "$adapter_id" ]; then \ + echo "Error: deploy failed — no adapter ID returned"; \ + exit 1; \ + fi; \ + echo "✓ HOT bridge adapter deployed: $adapter_id"; \ + mkdir -p "{{state_dir}}"; \ + echo "$adapter_id" > "{{state_dir}}/hot_bridge_adapter_id"; \ + echo "$hot_locker" > "{{state_dir}}/hot_locker_id"; \ + echo "$hot_receiver_id" > "{{state_dir}}/hot_receiver_id" + +# Invoke a function on the HOT bridge adapter contract. +hot-bridge-invoke *args: + @adapter_id="${HOT_BRIDGE_ADAPTER_ID:-$(cat {{state_dir}}/hot_bridge_adapter_id 2>/dev/null)}"; \ + if [ -z "$adapter_id" ]; then \ + echo "Error: HOT bridge adapter not deployed."; \ + exit 1; \ + fi; \ + network="${SOROBAN_NETWORK:-{{default_network}}}"; \ + source="${SOROBAN_IDENTITY:-{{default_identity}}}"; \ + stellar contract invoke \ + --id "$adapter_id" \ + --network "$network" \ + --source-account "$source" \ + -- {{args}} + +# Show HOT bridge adapter status. +hot-bridge-adapter-status: + @adapter_id="$(cat {{state_dir}}/hot_bridge_adapter_id 2>/dev/null || echo 'not deployed')"; \ + echo "HOT Bridge Adapter ID: $adapter_id"; \ + if [ -f "{{state_dir}}/hot_locker_id" ]; then echo "HOT Locker: $(cat {{state_dir}}/hot_locker_id)"; fi; \ + if [ -f "{{state_dir}}/hot_receiver_id" ]; then echo "HOT Receiver Hex: $(cat {{state_dir}}/hot_receiver_id)"; fi + # Invoke a function on the Blend adapter contract. blend-invoke *args: @adapter_id="${BLEND_ADAPTER_ID:-$(cat {{state_dir}}/blend_adapter_id 2>/dev/null)}"; \ @@ -1159,6 +1250,7 @@ help: @echo "BUILD:" @echo " just build Build vault WASM" @echo " just build-blend-adapter Build Blend adapter WASM" + @echo " just build-hot-bridge-adapter Build HOT bridge adapter WASM" @echo " just build-all Build all WASMs" @echo " just wasm-analyze [rows] Generate twiggy analysis reports" @echo " just wasm-analyze-print [report] [lines] Print analysis reports" @@ -1187,6 +1279,11 @@ help: @echo " just blend-adapter-set-admin " @echo " just demo-e2e-blend Full Blend integration e2e" @echo "" + @echo "HOT BRIDGE ADAPTER:" + @echo " just deploy-hot-bridge-adapter Deploy adapter for HOT bridge" + @echo " just hot-bridge-adapter-status Show HOT bridge adapter status" + @echo " just hot-bridge-invoke ... Invoke HOT bridge adapter" + @echo "" @echo "DEPOSITS:" @echo " just deposit [min_shares]" @echo " just deposit-self [min_shares]" From 25313c1b5abc2fc32d665f5d6f48ea2f7cc3e4a2 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 3 Jun 2026 16:47:12 +0200 Subject: [PATCH 08/18] test(soroban): verify HOT adapter against mainnet locker --- .../soroban/hot-bridge-adapter/src/lib.rs | 8 +- ...8fe11d5acbece0e4001612ebb31020fc92f97.wasm | Bin 0 -> 47193 bytes .../tests/mainnet_locker_wasm.rs | 152 ++++++++++++++++++ 3 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 contract/vault/soroban/hot-bridge-adapter/tests/fixtures/hot_stellar_locker_mainnet_cc74acb56fd01ef560b5d443cd58fe11d5acbece0e4001612ebb31020fc92f97.wasm create mode 100644 contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs diff --git a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs index 496911aa4..27f85fcdf 100644 --- a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -95,7 +95,7 @@ impl HotBridgeAdapterContract { let adapter = env.current_contract_address(); let hot_locker = get_hot_locker(&env)?; let hot_receiver_id = get_hot_receiver_id(&env)?; - let client_timestamp = ledger_timestamp_ns(&env)?; + let client_timestamp = hot_locker_timestamp(&env)?; let amount_u128 = amount_as_u128(amount)?; let token = soroban_sdk::token::Client::new(&env, &asset); let balance_before = token.balance(&adapter); @@ -291,9 +291,9 @@ fn amount_as_u128(amount: i128) -> Result { u128::try_from(amount).map_err(|_| AdapterError::InvalidInput) } -fn ledger_timestamp_ns(env: &Env) -> Result { +fn hot_locker_timestamp(env: &Env) -> Result { u128::from(env.ledger().timestamp()) - .checked_mul(1_000_000_000) + .checked_mul(1_000_000_000_000) .ok_or(AdapterError::ArithmeticOverflow) } @@ -562,7 +562,7 @@ mod tests { let locker_client = MockHotLockerClient::new(&env, &hot_locker); assert_eq!(locker_client.receiver(), receiver); assert_eq!(locker_client.amount(), 100); - assert_eq!(locker_client.timestamp(), 1_777_000_000_000_000_000); + assert_eq!(locker_client.timestamp(), 1_777_000_000_000_000_000_000); assert_eq!(client.total_assets(&asset), 100); assert_eq!( soroban_sdk::token::Client::new(&env, &asset).balance(&adapter), diff --git a/contract/vault/soroban/hot-bridge-adapter/tests/fixtures/hot_stellar_locker_mainnet_cc74acb56fd01ef560b5d443cd58fe11d5acbece0e4001612ebb31020fc92f97.wasm b/contract/vault/soroban/hot-bridge-adapter/tests/fixtures/hot_stellar_locker_mainnet_cc74acb56fd01ef560b5d443cd58fe11d5acbece0e4001612ebb31020fc92f97.wasm new file mode 100644 index 0000000000000000000000000000000000000000..70623e417eaf814a7a1f162341df38098163e4b2 GIT binary patch literal 47193 zcmdtL54apxc^^D8`{(Yxd#|(-P7sp8vuibxYwK!fc6VmiAwhFtY=rIjpJE_Qdo{Z| zE8qWDd-qBf$kMefi~s{B7%+qsH`v$&yKw^nLQD`1ZRyv9x?h}@uSu!<#Zc0u316G` z;pxK{f4|>*W_D-qy^>`k<>~WXX?JJNob#Udy#LR8&e_$1l?7eXH2we4ONWe8r?f+< zQ>RYpr+AI`^hx~bCl49&uHj1OZK_|Y9U5U3{Aq`Br_>cL+4|(6jNG#k;A$E^*cP<_ zSw48jKGS%QKTt((Q740{eUyO2DfXRaFZkp`xyZ6Ht7W3ks6YU)h`j+$1dx~##wpGC zw7yGk>Xw$mdqqp*eMa7A@qSc4jQ6|rvb#M zj^*?D^!Rutt4(Z~*jm`O{Z$kCmhJh=jO{-$VQl@$SHJqz+jeZ*vEwz{cJA0=XcH`W z^|ozWx9(u+w%2Uiwr%IO*X%%r9or29oZoIt;Ln)QUo|nIO`t&ArD=GuUDLMc+X0;& zOl;qx;o+b#i+oNWo9W%z?gq!S%P*T* zY<0t6C7hmFT1I(EUz}_XIj&9;c@MX{9zQt zkIix3^=1~rmEOX#RvhK6>ajF-=TdL_XxN?U9M`VQx0cXLceT}9>S}*C*GJ>(kr>o9 zTi7F*jnv{ZEX_lv&N>U+d(gw4j0=gJwH8tXBaF`H!+5kF?zMGd3t_l zx$8}RNk8%#{a*bYCr=z(xoh=){oVRIPwVf}kN18?g+Ji)ov2WlRme>l`G*a| zIAz3mm*+}G5^sh3G<|v$t_V#3}349ZD{zJ)9P^KUe7?U_H*3SSiDAXNk8-m{)<`T#jHjUuPm zW<+c=5^PJwY}1F?7TqegrNA~m=G)LHDM|@RCFA>}jO9FL)uThh)D=^7Qalz`@Bj=0 z0n8LR1%i=%{hmcym5NO1g>R7(l;8xP{&+o~^du>O8+GTKO7qx1H6#a|3;%||rtr45 ze4l(gn7cUY`Gs|ZK8Je3=0c1``-Os=splW*P|v6krJkwy zcA%aREs~#H5Va95kWgr*-Xbfdfe8uT6DmrSgvXSQdH;jc9AB)aIx`u~ zV43Jm;q&B8Nf)+|L;68j9#J~I@GtqSsLQyfa`#VVPZu%7fqIyF{@6>v7f?e&{W4Vd z!FMIcHPr8WvwHT_l|)$cp4QhH(?}Hmr4q%2Sc;K41ncv>HVe@#fk7LEb1h5Zl&UmT zXY|=pdP>CE@YZmXiEjvG$80H^yf4g_M$|o2pL{m>!7iZ*`V#i5Y047r7iCvFqz~%;AG=_b40FSeZ6l2DynKz_51O*_BW~fruN)_nwM)o z8o6(3hm@|Z{c!7j;N)pO$ekh6QGn|myhc5i@4+>i&%FChWDT@Bv%~0Vs@{ll~UJgWaFM$@6{X-S^etS zt2E7xi=oG=j%n=CT8?%#{o)?ukYnuO)m7S(tGVjxu&eC@4Dee3nuV`33@N5?`?MZw zc*=h&|7Ap*u`a-H_2UXuM0b2+)EQgA*r!m&H+v4g_XC(gg+u@aS|lpOwq`3s6U{L$ z!-wt|6v=1VsbfIqr_!!5j*|4O_kv*Zk5okU^tP0an5eJGXN|eiNMyT4%q#4{|r;~WtpnyIO^M;?0Y{jZ(U3jdV- zQ+sm79AU&zB6B!u=I)+a`}6zsJEqvWFbg#^QF!pF6L*(#Q`&82?v`Bwc=MjocjRNv zh1=9=+ODVnETt|U`CqBRzotdli2{aWdvTFnSd08aG*1C#2j_fhs<`^#sc6*G2r7_14j)7M%3q1n5aRBJerTi-d z)MxLd@CCx&OW_QD4EBQ39)equ1zhYgV05UqEQdE$a=1!W4&6?c!`iCy zT)!M+Q&a} z7V3>>EYu6X3klRpTl|Sk-rQo2n-hiKCB))5`bRH?Us3RJM-#*6|BNjZ9#N?>2+yeU zHR?p)9C-)N3dn@`^fjuyF3uaq zUzL~R^73%`p16F^aJfh&dpC#6MJibisYGzlhc8mea!6&e9Jp?Y3m}f#^O(fa2JY1! zF^XHnF7`?6oz-Gdc@|ZGxq7rFw0;)vTMAz#o!!*8l{9=?o*>RG{GTjm4uV$HjD-!I zk>H#?K4HWV<0<~rDDj>U|NFGO$7547eTdtH4`>iz2>sUxKTi?dW86}b2V)x^yh%OC zCmx`Svqlv1Akf$-AKxeEmAxF2j~|FcMDnpoyJsmso3#5j%VW7Rp|OR3NYT;q|9_2Wm^24s5^~l-j#R$P ze$Cufn5t**klK)3gd-(znpUv*wzaJ%~#Ewg;oO1`5qMNsy!VKSFSb zu^#3pcwX*9|2?vgvzZ*y0@4m|&m-o@c@IN+RBA`AIWHPj5w*I9zAM{y`yB}i>296+$11AdnlOXFNz%_Gwj9+q$Pm-p%d|PSUF@%>8yqAlj8N-2G zy>ciqhc87CEgZfSLG+Kq2~ZlABI0~M%!<`LD({C`vD!!F{V+?FLtB*u^}{T%^(x~~ zWC@hq(Wa;{3miYA7e|#%-2(wDjY*i5S7FwKIkqv(VqqL+9$*JPNZ!xRmuK9uj0>?j6~3B#S+!jiZcl&U=DBbQ2?@8uZ@@YRH7_>^~axB^3~# z^(T?%G(&3gxXCOMwrh-T7XwbZUC`0H*tdT@g=A(bUZHtbOQcd_k1i6ZV}`LMozipznR`RBZr<-pdg*sy@sL)5vwnO6 zVF0goxT*c1tbwqMbJBmo8b}o*>NluOn)iIN;7Y&pCz=B$pgPpN_;INslN9OXY7|myV8dVF00<8ra6Sbm5&|Sw0gn+H9sJD<#t44>T-k2}K|Sg-1Dv1j1Ni{FsqkVnnAIMHosF|HUg+ zSx+r6W)mw_T)`M&ravPKZqW(`vTy1(UcnGBx?1pB2Zov7q7E>*J{Ua$gXoxG^nwC| zUW{l&9G#kfswLZWf;5n8+M^7k!t`dO8IWTHtVGZXKBNne31KpQPJ%FhJVY4eNQVd$ z@k0=}knu%i;)+dzOmcgk@u^I8hN@V=m=MoyGO=DugCK%KdQ%!hx6c%@8WGbNou(5s zmRp4bR6AorPB32+vY5HTXB1&5&XR@0ZDdhMVixNVXC26KEI>~gmDIQ)LWf+P*cGU2 z2Bxd8bDz@JDV;WuI;eHLe49*2B-el_mLeSee1uxSRrJCLCwuWJ%154X+TEfsXR1+ zdi~yepx0aT2Ik3H{#DV?X@d%XMgHx~e^5u#9Q%&ql>f#p5QRTs&5{vsCpa5zC&+R; z0du)UgDMK^QTUInSv0tC*|&TKjF>)k--DD6*d}l7{`BLN7s{EAM2k!> zLSd9}7PIt7FjA(@j~ulG?BWUY>CKXX=SCQYB0o9EtxqL^lY8LLpf2uA^+bWN0jH$ZrM zB}7|ka0nk-K7W^)*2Hq=h*Y{^0*)4OATy`gt@*-NSXaw$)x3v@ zeBmD}R?j9E$su^Dh*j`^09wLQ4>~Lvg&(lF;t$GczJl>PsQI#xt_I@LyRs|E6_TCYF-47!TAF@i0@dbp5630fg=gb|(LJ-8kial9^aV z4P!qD33^D;tal1|LQ|hRQi4~;>M)-?!pb*+qf&(^6@HtLw<5o5rVtYIxoJ>YPiz1V z@Wy)Z2EeCHunt+_4d59^2zc4-gB~(b4|#w}GjJE7fS-eXKCnwoW_Chw+5dJR=RGR2 zwzcqiGAus^A{PFG&<{`XJo1r$Bt?BS{kOxn|8DqJ+pfwP&Rsrlx6 zA%TVGIBW@)Q&VY}IK7~FoCn2Y?Y#mp2_Uw_=~!3LmX>%Yp2yGnRvK~yLE7}uz2|;( z-?<;1mPe2u;8J*3^5%ZI$!Udm#}{Yh0#d9O-ZQv-uUtkD-ZvKMTJ|UNX zg(xbLQDZ;(?2W=E^I}4q+JYHl8u`*uY>2or1Bxb)Y`78x5)w6TK{HlCL#FCxK7VLb=pnU zaV2sYl@_G5jGhfiADSl80hjR&fDzRf9_K)?X$k~QgF;1c2ZSUuIRek<0sRNwj~>-R zCALMN^yxtXcTPkYKeGYjJ|@_@WWcdS7{VW5G=0o6s+Z>mKru}8@}ofk_ieuP0Q#70 zpxq`TQGFyva*+utRym`DyN}pkZy-^hg+ih}6GjF53Af*rgPu9Z+FNK9KLEKGb zhLY4r?n03vDrfr%4n1Fl)=WL|>|g!&&p!0%*Y8k~(}(`YpMUq6`~Lci?>~hQDqmua zfBmmM{VzWJ$!|UOq9hlzspr1;;J{q{cdQj!Re&wkz{=vJR`yYQARr%q)AOGq{ z{_>yyv)`5~f2ixJSP2o5M{yoR8G0}N6RPFmU*9*vDv3VBCh3S?Azpahu&V@rOP?!O zr&hcxBuJ%7u)W&SoMM!7IT!?c0Ij|ZM#IGC4n%{P^$}3YyKJ`bBJCtvV%JZOkq|z| z`BsQ=kP=L*G>Mw=0gHr4YY=ENy6_(T0CW!qNAxtBW|{p6Tr25>O-yL#q7}>h-ed(2 zh^*b!RUa_F8)|>gKqPo2Yax^$O8pr7F#sF_&}pF!8p3hj77ob5Jg(N#VzW^Ah9juf zy;j>o3V7p3z2|vHbD?|Lqy5JdUJAxWQfPxVTi4g>1BszdNR91By^$l{4+OB67NZ6H zF!&slht~Oej;`?kawKBEVfn?%lRPQRYq8yphK@K5Lk5Ot6<7m%Mio#Y6d=!;ItK|3 zf(`$Zu%%0J5fNa+8|QpqwnXeyHQ6z>A&%xfE&r^pr(-%pNOd(Ci8{m72(z2iu$ZDf zq!YL!j!r=)Az=^@N@vPb(wP<(!RbOeWtI#vrgZAUWk{!|%XFEg*hEbUB{)4Pp>PHw zq4)%QlM>1jkx*E8Qa~hBA4up8138P>FlUMvBe9Hg5DOS4DP@pSpw92>0!)bKh1c;~g#{4QhJ} zI7;}UP#h|wSqvP8LP7vU2SNcBbGpl(!PbGgG%%tRj9_-lj-#{) z2S&#V1r@2`iL%>RSt0bDsJ%nB3#7?H(IyP_Dr0v^wu_2hN-5HrE}~6L_`u!_w=gTH zU_J?=Hi7elI4P#_V<68UAu|-lesA1MN87eIQA~T|g-`X_j?`2R{V0P#bs;Ft`v$2d z60dwxg5saW^3i}iFkeQW=;t8MWt)NC3%bBl2v9YCl2s zHYPUu3Qm5dnZ3j)scBwr9bIxsx^VEK6(q-V!{m@Tz=p!lC%h-fl2v43u(8MZ0=1!p zePIx3xiD93P;^4tzk% zF2hv(fd_?i*mXnuGtSPRh&n%YBRfN{ge8R_0-is~=Ty}!4I0e;Hp-gDd)cwCphzu1 z9Y?NdkMT6yU;+_U?RUeO!$_1ckS;j>XgFp%u?+McC4wqjR(PK%2TbJOfkgq`W#Eqr z-Z;rXY)oTU)E8={HM|`d7@t9~LaVaJxLhm_gnAq!<-`Qu4%jL~SoknILt*TZ^GicU zB$DTW;DAbNSVoR?2=>=Qz?17oZ-$335C%>(l&NP(4(4~FNJOXbf%W};Ff|BLVPB#k zb+|*!R=Uddlo{r50p;v@RnZqq~MrB2e^?9$asP(dzktZxEm{T>Ijpud#;!<;4UFJC4?04 zGBl0^Rrn~OiW?rsq%z%uwTfa#hR(8%DS9TZl7kV^EJ(<9l_7>ngBuWF`0Se}ED)Ks12J~5BQK871jfO_#xsDtq1&tft88DOefMn>WjCdhc}!6IO64c zU8>wsx#N?g-7dtH^cNn0J6JkU5+Q<-MbJxLfR#|z!(QrWNrO2gD=-i6ih#o^30VXW`H|Z zorpInp*rP=2hH#if#(e6A$ANzV;g94Vc=P^$+NxF!kot+f0`Q1cVMa`Y6}f%j7Y~Kg<=f`XAGmj0ph+*Qb}z? z3AxmSjiH?z<^07P?N@}9$Ox&QA;8Nt)jRbT zuJ19Bl!-G;B|3nc?OSCN-A+j9M87lw5swQyUWqyud{7S#Dv~T(Z}f8=j)ha+VmymR zVnAC3G7c)o{Us_ggoc(AJKz8T1!nnvO?rk{fbt*B>Ep3VtwG@WTsGA-AhLz%7`6OgTcwI7Cg48$B2 z6Fmm#8FLJF5v(c_U7dI1!Z>c`&hwlby-X?38WXTNr?7sOS+=Q5;j&4!REEf(~GsN^#uc!L6j=dnW)0 z88BAH!30=+=_Q7)^Nxeq6nb8uoA}^}ILrx(Cd5dT0>>O?OB3dn>n75&E|SLS9=s0h z!U%{MX$2D)r)&U)c1nwBkp?Z|Y!1!BTD%qW|7Yo(@92Yb6y^n`?vS4Zf0@2Rj0b9~ z^c@Xp8tJ<%;uVM{2*yny2auzWwl2c(!n8?72}DP&p=2JpEU9LdFaTId^JV7G|A=`vs1GtU#SHktkU-MqhD^YX z{qaA--ZZ<&#;_2BDH=H%B?~40hlb)<`k6*@4&9KAY@5t9CfmYCal5FA#^6N6akiin zM@inx(fg?#Cs~{_QEWqHEx(l9S%P__zFX$)5CzG&>D~(uomH_ozpa6beS=gR4Ir15 z_CSzu87&%`xYIqAccWx^e@zH+;QrmLo{K&FAdHh>i<6__e=Hv7q6*Js!M1Tuk!CcS z!Q@6F5)2k2^CysxC}InN#K{OfSYf<`W#ri&EUK{!h6*jADl!iSiQYT`w#8Xnw8a6h zm=~q2k4Oiw7(-$f!l)I3j;K}0t0GNIplG?k>Cy;%02PtjXGRJiqoBzI7Kl;GumZp- z754n&X_V8?$N);5C8-HGb}HfuURuNosKBbC2aE!f1IjC|(t@Izlpr#^tUp6BRey@9 z6h0!0QNs>+Iwl+A<0Fs_2!3f4?J`G8BeO-~k1=zifv9heixQMfMNA-WVtE+mg>^7T zRYU*?j1c=r0Uyl~J4-t?c$qf#2cJf`08Fs!iBgG?&ncWTGEPc!c09`il<9c(ti~$) zu`t1v@s>t$#EeYGl0HI5nL0?z7*jZ0uB?+tS#$vuknSM;Dft-c6-S7;Xa(FVhlC6a zdIopD`w_?_l6Pw##b@C$HqxMK{Ktt?L>Fup=o7_&bCQV@LMCyo5j1H4NnTrU6BN-3?Sx>@-y1_^kQ`s;^9vH>Q|A2!i zd`=+`5sbrVGn;=0vQaV^P-h$o6a5IK2sg}sLIglnXpJt!!^}>siHH`rKhoqEuu`w2 z6WmhLh|=L%a=bzakOOJVKQz2RkqF`0J5We7GOAOs$(x(o}FAi0&~aN&fmlq7N=ti>w^nBNGo|CLt-_qZ9`M)Ofn1fD+iR zZ;FgWXDxPVz(JeP3cnhW9da3`d@&v!(adj1;*+bZZyxH@h&siHe@4fq3<@-o9ws_u z9{YihN#D$NL}`%m*}{FQ7nR`z8GcLMA75v+BO-$wwD!oN}6{S>>nck}zT!GE@ zcqxmq&#i&$2L6`JET2w%Fv`4JFCn2Jn` zY%Vf$#0-}DErP}&T86Z@ECyoGZ!Xai4jYIG<>$XC2eTB0cvGbFh_E$y7mAj~{uJ?q*~id{A=w(?vPoBh2)CI5_XF~I?ymDU4Vja^&eMh=i!Y%b zG#rMDu?h@leu%fhQ&S|2JDLzm;C24`dA#B4VDzp7T?-)4L(eO zBrkA@F88QTcH;wguox`lA^|?e6}yCGSTZh@G9U&n#+hXW-546^u&${dc>`WxL-&i+ zQsEgMYeh}y$e@yzMiL*0C`_`D8iVWI3EG2#bWiA=dnce05j&ygj6tO{d|)}syGv9Y zY{9%8;-L1tz$x}1A9{oetVxVXt~jce$EhmWA*3YyA0UNzaTqBCC!DNh5w^k+0X@YM z9m_C#nFRz*(c=#F1VJ=#M0S*`y zE;dpeHi{Hy*q4P1DPfs{HUK^u;JN)$C`KMb`1@eUvvu_2oCd?;SFmBU7JE2}0P8sZ9)3_IM&rHuC@@oQRlS0HNY`39}@i?2iXP1 z$OS*CTHG;cfl$VX24yJyH@t7F7zX;rEfV9f^8*xRy2hA;I|w*;o&Fx153^5#?8n=Ecr$%{zh2;_|q=LpD2I@$3KspsW zHLMY;Too+Bk)j9?OH%vM9YT4iP2Z_8IhwdhI5nnZ<0nN)xzVYqY^*s-r$$i4ae>+g zh?-Hpb(CIaRKy=kEd4=}$iN<=O;TJ|8t2E3(ik*`!~9KBo*GT3V2%x}91ce97v^9h z$2GuL2m#*+7S9F>Fd~{)rr^`Ff>VMN7j4b+;sylf$YGuS4n_gvgf-;A%fxuSHx;lO z#)JAW<%0nVL-C}^+&ybvj> zl>akkL6ok5{;65y*_lkif`2p26*}F|z7_<1}79lu^5_q88A4b9hY>*>R2&BV`@Qe5SXygbk z<#X1%XDvOCb8MW6AYsBd6t%P7j}R3ZaJF=qh$9s0+yxtA`CT`z~0MfA0d~HQn-^6kRHJ| zW`-rMU8=bRRV8tgkVZXeb{WfJssaKiLWLs(Joqi`7a=YfDy;>|!5=Pz zOR^8!P%sjyw1)3{MctEkKMr?|BbP_K7qms#8olQ|@GwxG-KS|kqd|4OliY8BugB2l z=Tyb@RbUEaIyUv<*ZE9U@jhqCjTi<8b)XUUI~dGmIHXd=j^#V02lzm zp?(@rHI@hn{KGK1DrDej>9){K)+=LoIrNQ63kis6;r%qGpM|H%QJM6VXQNmvo&RY_ z6=eVkF&OYGR!hbT%!lt2Eq-iNdo4tT4}>`83JNb&R?0_Bs; zdEy!FMo*pq+0FY;l*Z)rH#BGTBpzlDswfk6PM?5>8NnBFQb12qK~A1HfxV6Sx8hWw z5CTS0W6~TJlhVqB#8aj8{TLt0=6HAJG;NfbzF+xVnpBkDUo@sPngPhN>K$JPmCJwK zi2aq}6_|a|u#cFv08X2O5e6A$1DB+L@X$n_HMJj@W%qKXLlJ>$BC$+ofk_d#1b=~; zn~Bm9x<8n`l%Nir;Cuj+J_NFcR0vFUss|C-aRfMzvNo5+83`~9K`B-a!3*h)}>F&3!~j{wkEC`0cu{}bqNRANd%eze)VHgLtXOM0h$mH^Zsejq&tE+f^W zkwziF@JO}%lbMKwGDx6=q$?>2_F&|oH$KwFei*5Uz!@gD=|Sn)aKSUnbF#n@rBe)^ z*;N@85o9wLVSqdueF$<812SjlKvNJ7AlM70b;!;{LNj!pxi=aVaXVEmPF18 zkue7(k(t8DaIYE@;i;aYG~%VB5vAD%a!D4;HWq;n7Q(*>yg~B;y_0P)F&7f`2@&sz zk0cEt2GWa^Y>FWiAWkavM-NFuik_0D(1;`rL6wjI%O4;CKtSd(J&lL}7fMOPZJ!9) zAxYav1V)LGq>&PvNE#U+k|xk0t>=}rh+UL@$T^lYC}~{Xf|kT`hVc%|Sz??*Znjpj zICr{a=O|oggfto_N)QYk3{aLY0gB}Vfzk56rRymbp!V5teuW26C_OcOSnwkiryQ3k z=peg6N>eA9@L~8hFTtfuz&Ua3>nQjp;jd&tH>lgjzBD*{tkG(!7s!VM{g^-?fh%Jz zWv&N{;r^MPi1ai*$!SD87BUFaK&ioz@2`kQ|6De$zIY-NZ|cX~JF^>;|21?I|1(p! zU}R~2*8TJi{2d(-dq;AAj65jz3Nl_@Z{C@Z0QaijkU_VyG~8 zr=}3j^J~xx#W4a+p*O%By&KTS4nB2v30>WeUg1SfOP6DJBkV-Kpq{*;-?W+M!JV-w z{fvpPgSh`RwqE3L13ek#$0!FM4}#!147iPh$e-Z@-!+2|T&!WPnmT!R5d@mz&od!n0X$^B+HitFfQ+!VoznubSxyMfz$1%1%S1O~L z@D;2nr4U_~8f&oVMqE0D@1EsXNv$6%j1NW#1a-JLV1gSEj@ZiIsiFaxFaW9j7$`Dd zeLCh89YIxM2(IC4r0`wW7-5b%wISjeq#BseCmzw@z|n|o7Yan!BR9!i8)@$iX6krp zlpHUOJw68yjjwSuM{nI#8d=YICbZ>SX%y#?xJclf50acWq$o=zKg136LmI-;}07G|jw<5ypA0B7g|Eu(4MX{> zNQXco(i~(ee2ul_d;dXy8U&#PyMSu3!_9hnMnLC(ZKtkz+y#?=c&B!bKm8o#Z(LgJ zb%R#VoLMo0dHm2&`?xu?IMbU6=4aj!wzaZW(5^t+m2PX_{7iGQyP|1tL)jS0s(4M{ z72hK&)($4_y><83ue-l_-ThnF-QS1%x8ZeQ@hGrqn=3Pi7lYnvH$>TUz+sy_SGar# z*TiLUX|WaJ=ZKbPx?%h0d1FR5c&-d294zOML$_j?BRndk-jmJ0} zwMo)#aS7xb&}`DY1;5R-5}K`Ge%?&{ppw~{>8|u9wL1Zmw2EOq62W|v`Y|Vyzq(}h z?gU3+XVnul%k&~4$$ z>U{6oYgZSKb%W(SR~<5!7R_MMJmhz~hfK)RYN$~sa?#7c=e}Q93A-!%mX9vYFD@P3 zH`xlhhnMzs!^0qZ_xL`lz|3Mhylb-64SM0qqLQDL7(pDpzD2E0fk$S!{G zYh`9>5yV*Pz8sQiDYWMwX_v<965<~&9u;$=EvE}wfWA&gGII(0Y#t|LL9-XoCu#O1 z1cNm5x=_kDEiD8yiw7tJQ%kFh?Ul$f9avmm?cFr9vNE%H_zsGePfZ=%w8F3*wr`wS znCV3=yjBn7Kd{&fyNkiRu;CXLVW`3uenjoy@#S!EcRTEGY;E3(Un{(0fp_6uz1#5{ zZNcI2=H@I~y|H_IxwqsWg#lQZT7|In1QBnwcc(z#bB%fBRT#&svIQ-R&)49Syu2Kr zJ{W)I$4X+>K z^*4B>(bpJWTk*=H9$(aZzQ~W{i+Uus2klyTE#mbKyza;A!+1S{*Hd_X1+Q=7MH@pn z?4N!thOK^&59Stn6OcdVQpYq!B+O1Q10B}1*N+smpFo>i@kt!u6T1P8L$5809xL54 zp6|f(CWy=a*4JAO7H3*>5D^I5!>D^X>bws`e;q!P_^}U;jdqIoZe4d@z&-sSeytE5 zQPp`BJ}<+Ic=C_@{Ykt6xU{Z}OO57QbIyGI&zdXYd}ne#T-1c=(XZg=2g7{}OKnlypUoAt zo6zq&F$j+7V(4vmetB4gg`@Q3gO$5BIRToLtiqXo{H`f%!igUQw=G?O1(q;SNf>n-(y`RQXY5Iquc z0Amg?IPMs?Mck7w@%=k-Pu|7%$JX6H7TsSAx``C;wI;3dWLbG63FdvgMQ5POi_OD9eq%@C8&5Q z7BGfW6~Pou`vThHLwgRNrsa2ecOZBgot5(&nR;tx%t?E?W zYQ5U1m1|bbu2pK)TCL{P+*-ZXaLSJ5*iOZ%IyJ{}T&M0d+_G!Awp($lZq0RE*R8va zI&4VYu2<^Sdadr%-Fm&=XaL0qx^Dn<1I-#J>IRF4LlZxK+GU&#=WE8qQcpTdT~jO% zMHpgx7~%7Cf*Jk|>zp`PBM>bf1V)g_U9TxvGy`{NuQC+X^fAimr zRdti^>wZL59`$jRxreG9_Zhcc|6{W(kK3J(W^wz$@5(CWV)>?H+ysZs#+~bit#AfL z0dQhkj3#G`>v$LA(f?6f6i~_e2i`%%y3;do_WXoMd1iX>Mr5w z+UvH(N{!jVLf8xXdd+}G)0X;5KlCuuT~X44Dp_mNZA=c#L9V-k$(wq-Pfg*jv@NUV zG=iYoXw}>G25gnv?z94{V~5RZCv3pD!Kk+2fZls7y%;Qn+KyMR^uqah{6sTS-6rRn*Z zMgHEi3Q;Zh&RV_PsRi}WaqE?^Rjc~?Z7&J3u0Sq^$EKMU*Z{Nb*mlrxoMvcO>+N>S zH*SAPm<>#g5f@CK6KKP#2hC2aQf@ev(5|)o)P(>&zR+BnKR3{3*lbtridA|@Bf8^&c1d?6mFLSF*!@$GNVG@xzz?@5`XNxbQc(9!hlXF2$b5eUTK!?pw$j4fuDcN zOJQLP1cilV*Qb|f;6FGb$LUmC9j8v&Y9pBO$FF-SSe6rGLrC*WGODmvZIzJ~s$12D z8(39;;$_0Qb4K8}HM?AMtZJnllrM6O51HTT?@9WS@XA^7nY+0 z$#Rj}7%-nk7<6ict{sH!degS*od8N*xG-3E1}ht2xgAV45k}Tp%}&Q|xc;^agC)Wl zMRf9W^OUA)E$GVM`05j|5fKT+7Z3%Dy#w` zh+%Nkz1}?0gKYp4C#_I&+RIjNC1;26}(R=pl{I+h)@5y$&Kd0sO{UuD&iS5ONER7>$P6cV@L z)|-|SbQ;x`T`Bvo{<-sEK}Zedp%BBPy<-J--Dx*4BXP=@nfNNq_Y9-oF0T?wXF!qzfi z&RKxkwt`O3L{NZfQrm{X*m+(>cVpvecf%vASfZE?ke~ycO0C|iSuW(i6~aP?{;t6s z;_`J_!nLa1X>^<#^f_!-T>qzp zz88j-+g1xIfP>Jj7BpO|Q?3Vf7ksbct1b=%O9_Bq?zGF*M$oC$+_2SZl>I#y4j)Q2@FyU986)%^+EHSv^b@49)! zWOA;sZ|C$hQ;E}(S(y&G$^dm-Y|n??@>@Z8#5~=wvfS&|D(&vl^7PE&^zwYr3Q6Ob z0StTHALOzRt1@aI!t}wTl%yu@rM;d01IPnW*87?BxvZj?2gMt^w?NQ40r9vw6dTqm$QRlm9sgB6vgccXN@ zWzz|QwUYUlfplP^Ta9)F6Nq+L_iGmbgU&Ajqhpn8tq>WPN~>CF0T0JpKMFU(qkbTA z8df=SE6O*8ji6PlL2;YSuz_V@-<3ctNr;+EcP;B6GY@L9v5thw;z}o^(ueJ46I$Bn zxGh}O1HUewV+eu>gyl@FE-c@&Bm=k+x%PRhybv63h8WTe1K?Jt4X07Bv>}RRrxW;% zmjOx1+zQfzfK<2OI69q9)2YG~!4qEFUQd|Xxo~m^kboE`U{*sicM!|@HrJRXn}=SS z?GBN{?B|yXE(MZ_9Y>YlanhlwV9l}T|PInJW0WaS%vfIO->%{9#7bz zA&6q6{KccNO}lTqZa2(uY@sY0>y|3oR%Qfp(h2HyOjX-R{#9)M^^Nu9h}@{77L=U6 z|3si}w_zO0VXYDb^_Ks${`s3?dK){+>BxzaRBol*Y}zekA47zFmj83(Nn7!TEzw;& z6zcDf?~&){vW#~ZI_+Bz$GRZvfw50c!c~Ei{YB5ninIe4*kI~sxnad=H2ul>`J)Sy zRfHThq&%&vi@+4=%vyEoKC;wxhd7#K)uz2991Bg}$F^C-`kx^CkQ)k-M{|WMef_G59KrBj_q30 zYITr02$)cJE&t6+>{talF(Y3Qr-jQKlNDv8>Z`CTPBUm?&9LJiSh$!SuFn@Vuzt|C z>`pDLB1>a8{I{%L#O}_S7zmoxM!W2SvThYqBgent$VKdS(;R};4(gWEs$m)$)T>Uz zzwy#_8KoQSdK=S^Mo_k^?a(bZ{hPWMbCjE>65L8F#Kf;v!8FRTI{wdJ+&(v(+FOlA zSZ)W1)!U7#W7qwg#VB1QB{^>jz(S@8i(*#GYFfy5IQ}gw7n8)B&%`Ssw55h6x>loA zslX`QI&%>_9h$TUVXfQ<+ASJvlkW9XCmDqx>Xts*{s9 zciZA6Rf07vH^OEJpYH^SFrAivyW|s+&afsxYXTF&&r&o2fu)2i3L0(XtC}IGUV~+9 z`UfQqdd~JW@ntdzQ`Yqy(VtrlYDn}!d^-)R*=qT}a4$oJ`mlGU^OWrV*-5J3j3Vi z7nA*V6N$h8;W#EW75{CQ7L9eQ6|pbIGC&P>w2s+nyV7a3{kPw9>8NpzcmRv}*qMmf zGN{`1X6X9ANH0TGxx;H3^YE!A?fTE;R&&8b^~Rd@Wq|88OVMW?s@Qj0T{N>FLQAUT-Khe)mX z|9}zbCE&vP2rUQ#Y~Da<=C+WC532q&BeYA<@$fQ`+iANM%=26Awo@xtJN}_d#rnYt z*3kM&gNuI-=5ax2)hl-32k*Qz7BFu=YY4%rLs*a1P9ypH>r&((@{q~7VL;6i4SR2`{w$pIh<)+oaf{`Cys@^wS zJ`3x1lY6wRvgLOV#_PaeTd5v0Ke%71^vh z5;*>yD(JcR*7kg>dZFD6D?tM%00OIBZ8rRwhcAuIfB6f6R;v;=TU5oiHqXh`#dkeJGoDtv z*2JhAwXg;quv-4Y$xG7dxfV7X6~_+kI()8+?E+YfU%da)h~G=Cg$CHH;5ZHBN?ULO zR>NPqR1wFz#Zv@b?P{ZLRS<7tBWuH7z8M^VjHt6Xvb{>JBsnVkZdoe(e)|HF%2u_X zB$wGEW{OzthZo1>q1I`(keTq0900(on$R)FelR(0OD1+_RhxM34p zO3GNBv0Hw(2~bXBDDyfr;Y%)cC6choUe=|LYZf-;Vxb({R$ze;bgnQpUr~z>o1hvj z9-o}_76nbWhr=e=Y==?T>+MRbX;&IJt5Oc?O~3bwz%;m-$HJ-&2JSjd+h2V}U_#_a zoK6Khh+$J2FeOKsNKlJ4ZnRI^YTOnayCZCI+3+BiKRlUMg%NJ7{oo2OJFNf`Fxzp@~W&ijalIUU6$@*2+-8bHX)grtUfKII* zhRCdxvDKoE6te#g?zQNTuexz~(a=-^9C*q$zzAAcZZ=woMw%`-SGHXLolHXap}{w- zM+e7eCMO4HVkVn7bcZ!z9+HU{ju=ZwZdiF9nhw!uT=e0%s5ifDrX2 za-}Dx(P6DFTDCc+o2!)wP`vIVk_`H?ji(eO_s-AMU~o z;!ps9*vKJci4c~m6Es>u#XtG?e@rYRAq5ivzEQCqx7M*+h=rO~rQ@HnH{xh@u*Qmg zF+>Y(f-?3Mz;0G>tOr~BEq_hF35_b?hgDuV&fQvEfp0W})*ScFV|!$@K*v2!D|aq+ zvAH#V26$Krt+d-A@@&v9?DhBW(cc!qQ`s{fd9oUtuos)>cCWh4-SXC&Us}OAUtG6Y z(58%YYKJ^{727}>L5L*gz535YpcNmOwC)5r^*OxlU}Q4XEg;y&J5oC$6pT5PdQdnuKI{pV0RW}1-Rd9q4+vMa7U&t^8THdso?dV|1 z95I#upicL_DGC*wYeN%mM8p~zcFz zZU#))9seUb{rfuqwazzS11L5=;OHl|C3foAbEbABV5?Ep#mOw5@3OIN8wVq-kLs_$ z9R}qPaGZeq5^#FL^*^R_y0#9fR~F(06>QoS(Y9?D3D6jK8{5pW2Nc`u9mmG5glffo z_yS1Lt~tn{x7-f)zSvEy+C`+;1l-5Oh9U4QNltDOa(z@>g`3QK(o)x<$arMKw7r zsn*v3Nen`?$5DltOUp1EjKr`Z1@DY{s=Ni7b!~h2Zlb#9l^jG(qJJXKXupN! zyQbU3MvRL8gmN7(cXExbyKo)l1`a2{3U@63vpS=58I=PaN7!mr)R&bAmz1l+5evsH z+c5S)h4ZLiPc&MR6UZAImD@b)>tZWux#KplpY(H^G*SoO2R-40Z?n>30?TdK5Dfpx zfnyoSq7~V*wxTb|gOJ<`a7rm`R@xnGJbg-k#irf$pCvyr@GipfFrzC6-f!Xf<6UZb>^tuz!oYf zMiVhrHSm8^=O(|G?$cH?)a7p2nc)lqYad}9$q9URu-DG=Kd)Ce2Q)NoY+6;AK+OLr zX|+zp|3Z>_z}f;wjZJPYUezcnz8L)NKrZtFDNWx$5C4ECvD z49L?|acTpFSci1-r2Z9q) z*Z|eRJ{te4FV#+My=h?ipkcRa4$g(b?^*uu>RC04uj%Sk)W(rwp8hwWnD}ml>fW-m*D7D7U9%NmLBLh8PAFePwqjhp6~P*(U3(AD T<7=PhSFX Address { + env.register(DummyContract, ()) +} + +fn hot_public_key(env: &Env) -> BytesN<65> { + BytesN::from_array(env, &HOT_PUBLIC_KEY) +} + +fn proven_receiver(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &PROVEN_HOT_RECEIVER_ID) +} + +fn register_mainnet_locker_wasm(env: &Env) -> Address { + let locker = env.register(HOT_LOCKER_WASM, ()); + let args: Vec = (hot_public_key(env), dummy_contract(env)).into_val(env); + env.invoke_contract::<()>(&locker, &Symbol::new(env, "constructor"), args); + locker +} + +fn invoke_locker_deposit( + env: &Env, + locker: &Address, + sender: &Address, + amount: u128, + token: &Address, + receiver: &BytesN<32>, + client_timestamp: u128, +) -> u128 { + let args: Vec = ( + sender.clone(), + amount, + token.clone(), + receiver.clone(), + client_timestamp, + ) + .into_val(env); + env.invoke_contract(locker, &Symbol::new(env, "deposit"), args) +} + +fn get_locker_deposit(env: &Env, locker: &Address, nonce: u128) -> Bytes { + let args: Vec = (nonce,).into_val(env); + env.invoke_contract(locker, &Symbol::new(env, "get_deposit"), args) +} + +#[test] +fn pinned_fixture_matches_mainnet_locker_hash() { + let env = Env::default(); + let bytes = Bytes::from_slice(&env, HOT_LOCKER_WASM); + let digest = env.crypto().sha256(&bytes).to_bytes().to_array(); + assert_eq!(digest, HOT_LOCKER_SHA256); +} + +#[test] +fn mainnet_locker_wasm_accepts_verified_deposit_shape() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_777_000_000); + + let locker = register_mainnet_locker_wasm(&env); + let token_sac = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let token = token_sac.address(); + let token_admin = StellarAssetClient::new(&env, &token); + let sender = dummy_contract(&env); + let receiver = proven_receiver(&env); + token_admin.mint(&sender, &100); + + let nonce = invoke_locker_deposit( + &env, + &locker, + &sender, + 100, + &token, + &receiver, + 1_777_000_000_000_000_000_000, + ); + + assert_eq!( + soroban_sdk::token::Client::new(&env, &token).balance(&sender), + 0 + ); + assert_eq!( + soroban_sdk::token::Client::new(&env, &token).balance(&locker), + 100 + ); + assert!(!get_locker_deposit(&env, &locker, nonce).is_empty()); +} + +#[test] +fn adapter_supply_works_against_mainnet_locker_wasm() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_777_000_000); + + let admin = dummy_contract(&env); + let vault = dummy_contract(&env); + let locker = register_mainnet_locker_wasm(&env); + let token_sac = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let token = token_sac.address(); + let adapter = env.register( + HotBridgeAdapterContract, + (&admin, &vault, &locker, &proven_receiver(&env)), + ); + let adapter_client = HotBridgeAdapterContractClient::new(&env, &adapter); + StellarAssetClient::new(&env, &token).mint(&adapter, &100); + + adapter_client.supply(&vault, &token, &100); + + assert_eq!(adapter_client.total_assets(&token), 100); + assert_eq!( + soroban_sdk::token::Client::new(&env, &token).balance(&adapter), + 0 + ); + assert_eq!( + soroban_sdk::token::Client::new(&env, &token).balance(&locker), + 100 + ); +} From 2d2a11fe0bd1b18889bbbe3de5cbdc0aca5de8e2 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 3 Jun 2026 17:52:42 +0200 Subject: [PATCH 09/18] feat(soroban): emit HOT deposit nonce event --- .../soroban/hot-bridge-adapter/src/lib.rs | 48 +++++++++++++++++-- .../tests/mainnet_locker_wasm.rs | 45 +++++++++++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs index 27f85fcdf..0bc5a8428 100644 --- a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -109,11 +109,12 @@ impl HotBridgeAdapterContract { adapter.clone(), amount_u128, asset.clone(), - hot_receiver_id, + hot_receiver_id.clone(), client_timestamp, ) .into_val(&env); - env.invoke_contract::(&hot_locker, &Symbol::new(&env, "deposit"), args); + let returned_nonce = + env.invoke_contract::(&hot_locker, &Symbol::new(&env, "deposit"), args); let balance_after = token.balance(&adapter); let spent = balance_before @@ -124,6 +125,10 @@ impl HotBridgeAdapterContract { } increase_principal(&env, &asset, amount)?; + env.events().publish( + (symbol_short!("hot_dep"), asset.clone()), + (amount, hot_locker, hot_receiver_id, returned_nonce), + ); env.events() .publish((symbol_short!("supply"), asset), amount); Ok(()) @@ -436,8 +441,10 @@ mod tests { use super::*; use soroban_sdk::{ contract, contractimpl, - testutils::{Address as _, Ledger as _}, + testutils::{Address as _, Events as _, Ledger as _}, token::StellarAssetClient, + xdr::{ContractEventBody, ScVal}, + TryFromVal, }; #[contract] @@ -549,6 +556,32 @@ mod tests { (adapter, admin, vault, hot_locker, asset, receiver) } + fn assert_hot_deposit_event( + env: &Env, + adapter: &Address, + asset: &Address, + hot_locker: &Address, + receiver: &BytesN<32>, + amount: i128, + nonce: u128, + ) { + let hot_deposit = ScVal::try_from_val(env, &symbol_short!("hot_dep")).unwrap(); + let asset_val: Val = asset.clone().into_val(env); + let asset_scval = ScVal::try_from_val(env, &asset_val).unwrap(); + let data_val: Val = (amount, hot_locker.clone(), receiver.clone(), nonce).into_val(env); + let data_scval = ScVal::try_from_val(env, &data_val).unwrap(); + let filtered_events = env.events().all().filter_by_contract(adapter); + let events = filtered_events.events(); + + assert!(events.iter().any(|event| { + let ContractEventBody::V0(body) = &event.body; + body.topics.len() == 2 + && body.topics[0] == hot_deposit + && body.topics[1] == asset_scval + && body.data == data_scval + })); + } + #[test] fn supply_calls_hot_locker_with_configured_receiver_id() { let env = Env::default(); @@ -558,6 +591,15 @@ mod tests { asset_admin.mint(&adapter, &100); client.supply(&vault, &asset, &100); + assert_hot_deposit_event( + &env, + &adapter, + &asset, + &hot_locker, + &receiver, + 100, + 1_777_000_000_000_000_000_000, + ); let locker_client = MockHotLockerClient::new(&env, &hot_locker); assert_eq!(locker_client.receiver(), receiver); diff --git a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs index 2238b126e..0f267527e 100644 --- a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs +++ b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs @@ -1,8 +1,9 @@ use soroban_sdk::{ - contract, contractimpl, - testutils::{Address as _, Ledger as _}, + contract, contractimpl, symbol_short, + testutils::{Address as _, Events as _, Ledger as _}, token::StellarAssetClient, - Address, Bytes, BytesN, Env, IntoVal, Symbol, Val, Vec, + xdr::{ContractEventBody, ScVal}, + Address, Bytes, BytesN, Env, IntoVal, Symbol, TryFromVal, Val, Vec, }; use templar_soroban_hot_bridge_adapter::{ HotBridgeAdapterContract, HotBridgeAdapterContractClient, @@ -77,6 +78,32 @@ fn get_locker_deposit(env: &Env, locker: &Address, nonce: u128) -> Bytes { env.invoke_contract(locker, &Symbol::new(env, "get_deposit"), args) } +fn assert_hot_deposit_event( + env: &Env, + adapter: &Address, + asset: &Address, + hot_locker: &Address, + receiver: &BytesN<32>, + amount: i128, + nonce: u128, +) { + let hot_deposit = ScVal::try_from_val(env, &symbol_short!("hot_dep")).unwrap(); + let asset_val: Val = asset.clone().into_val(env); + let asset_scval = ScVal::try_from_val(env, &asset_val).unwrap(); + let data_val: Val = (amount, hot_locker.clone(), receiver.clone(), nonce).into_val(env); + let data_scval = ScVal::try_from_val(env, &data_val).unwrap(); + let filtered_events = env.events().all().filter_by_contract(adapter); + let events = filtered_events.events(); + + assert!(events.iter().any(|event| { + let ContractEventBody::V0(body) = &event.body; + body.topics.len() == 2 + && body.topics[0] == hot_deposit + && body.topics[1] == asset_scval + && body.data == data_scval + })); +} + #[test] fn pinned_fixture_matches_mainnet_locker_hash() { let env = Env::default(); @@ -131,14 +158,24 @@ fn adapter_supply_works_against_mainnet_locker_wasm() { let locker = register_mainnet_locker_wasm(&env); let token_sac = env.register_stellar_asset_contract_v2(Address::generate(&env)); let token = token_sac.address(); + let receiver = proven_receiver(&env); let adapter = env.register( HotBridgeAdapterContract, - (&admin, &vault, &locker, &proven_receiver(&env)), + (&admin, &vault, &locker, &receiver), ); let adapter_client = HotBridgeAdapterContractClient::new(&env, &adapter); StellarAssetClient::new(&env, &token).mint(&adapter, &100); adapter_client.supply(&vault, &token, &100); + assert_hot_deposit_event( + &env, + &adapter, + &token, + &locker, + &receiver, + 100, + 1_777_000_000_000_000_000_000, + ); assert_eq!(adapter_client.total_assets(&token), 100); assert_eq!( From 47d4f3d9d0603dc263556c904033d9ec99cfd0a0 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 4 Jun 2026 11:20:02 +0200 Subject: [PATCH 10/18] fix(soroban): harden HOT adapter nonce handling --- contract/vault-counterparty/README.md | 58 +++++++++- contract/vault-counterparty/justfile | 102 ++++++++++++++++-- .../scripts/load-stellar-secret-env.sh | 45 ++++++++ contract/vault/soroban/README.md | 8 ++ .../soroban/hot-bridge-adapter/src/lib.rs | 88 ++++++++++++++- .../tests/mainnet_locker_wasm.rs | 77 +++++++++++++ contract/vault/soroban/justfile | 31 ++++++ 7 files changed, 390 insertions(+), 19 deletions(-) create mode 100755 contract/vault-counterparty/scripts/load-stellar-secret-env.sh diff --git a/contract/vault-counterparty/README.md b/contract/vault-counterparty/README.md index 641d5d1c2..dab5bc5ca 100644 --- a/contract/vault-counterparty/README.md +++ b/contract/vault-counterparty/README.md @@ -5,13 +5,21 @@ This crate is a spike for the HOT Bridge counterparty described in the Stellar < ## What this verifies - Curator-only methods build the expected outbound calls to the configured `omni_contract`: - - `mt_transfer(receiver_id, token_id, amount)` - - `withdraw(token_id, receiver_id, amount)` -- Each outbound call attaches exactly `1 yoctoNEAR`. -- `mt_transfer` always uses the preconfigured `near_market` receiver from init. -- `withdraw_to_stellar` always uses the contract's configured `stellar_receiver`. + - `mt_transfer_call(receiver_id, token_id, amount, msg)` with `msg = "\"Supply\""` for market deposits + - `intents.near mt_withdraw(token, receiver_id, token_ids, amounts, msg)` for HOT withdrawals +- The market-like adapter entrypoint accepts vault supply through `intents.near mt_transfer_call` + with token id `nep245::` and `msg = "\"Supply\""`. +- HOT asset transfer calls attach exactly `1 yoctoNEAR`; market queue calls attach no deposit. +- `forward_to_market` always uses the preconfigured `near_market` receiver from init and credits + the counterparty's market supply position. +- Curator-only market withdrawal helpers let the counterparty enter, cancel, and execute the + market supply withdrawal queue before returned assets are withdrawn to Stellar. +- `withdraw_to_stellar` always uses the contract's configured `stellar_receiver` and only spends + the counterparty's current Intents balance. - `token_id` is immutable (`omni_token_id`) from init; runtime methods do not accept token overrides. - omni contract is configurable at init (`omni_contract`) to allow bridge transport swaps. +- `hot_deposit_receiver_hex` is explicit init config. It must come from the verified HOT receiver + bytes instead of local recomputation. - No runtime callback `msg` input is exposed in this spike (no arbitrary action payloads). - Security guards in the spike contract: - Reject `amount == 0` @@ -21,6 +29,26 @@ This crate is a spike for the HOT Bridge counterparty described in the Stellar < The tests inspect emitted mock receipts directly, so method names, JSON arg shapes, gas, and deposit are all checked. +## Market exit flow + +When funds have been forwarded to the market, they must be brought back to the counterparty before +`withdraw_to_stellar` can bridge them out: + +1. `request_market_withdrawal(amount)` enters the counterparty's market supply position into the + withdrawal queue. +2. `execute_market_withdrawal(batch_limit)` advances the market withdrawal queue and transfers + available borrow assets back to the counterparty. +3. `withdraw_to_stellar(amount)` withdraws the returned Intents balance through HOT. + +When the vault uses this contract as the Stellar market adapter, the flow is: + +1. Vault allocation calls `intents.near mt_transfer_call` into this adapter. +2. The adapter records a vault supply position and calls `intents.near mt_withdraw` to hand the + wrapped HOT asset to `bridge-refuel.hot.tg`. +3. Vault withdrawal calls the adapter's market withdrawal queue methods. +4. Once HOT has returned assets to the adapter's Intents balance, `execute_next_supply_withdrawal_request` + transfers the Intents-wrapped token back to the vault. + ## Soroban adapter compatibility `stellar_receiver` is a plain string provided at init. For this spike, that lets you choose either: @@ -35,3 +63,23 @@ The tests inspect emitted mock receipts directly, so method names, JSON arg shap ```bash cargo test -p templar-vault-counterparty-contract ``` + +## Operations + +Use the local justfile for deployment, bridge smoke flows, and HOT/Stellar helper operations: + +```bash +just -f contract/vault-counterparty/justfile help +just -f contract/vault-counterparty/justfile stellar-deposit +just -f contract/vault-counterparty/justfile monitor-withdrawal +``` + +If a tool needs `STELLAR_SECRET_KEY` instead of a Stellar CLI identity name, load it from the local +Stellar keystore without printing it: + +```bash +source contract/vault-counterparty/scripts/load-stellar-secret-env.sh +``` + +Do not use funding-bridge scripts for counterparty or HOT bridge operations; this crate owns the +counterparty deployment and Stellar locker helper flow. diff --git a/contract/vault-counterparty/justfile b/contract/vault-counterparty/justfile index a346db1cc..29353ba92 100644 --- a/contract/vault-counterparty/justfile +++ b/contract/vault-counterparty/justfile @@ -4,8 +4,13 @@ set dotenv-load := true NETWORK_ID := env_var_or_default("NETWORK_ID", "mainnet") OMNI_CONTRACT := env_var_or_default("OMNI_CONTRACT", "v2_1.omni.hot.tg") STORAGE_DEPOSIT_NEAR := env_var_or_default("STORAGE_DEPOSIT_NEAR", "0.25") -CALL_GAS := env_var_or_default("CALL_GAS", "100000000000000") +CALL_GAS := env_var_or_default("CALL_GAS", "300000000000000") WASM_PATH := "../../target/wasm32-unknown-unknown/release/templar_vault_counterparty_contract.wasm" +HOT_STELLAR_LOCKER_CONTRACT := env_var_or_default("HOT_STELLAR_LOCKER_CONTRACT", "CCLWL5NYSV2WJQ3VBU44AMDHEVKEPA45N2QP2LL62O3JVKPGWWAQUVAG") +STELLAR_RPC_URL := env_var_or_default("STELLAR_RPC_URL", "https://mainnet.sorobanrpc.com") +STELLAR_NETWORK_PASSPHRASE := env_var_or_default("STELLAR_NETWORK_PASSPHRASE", "Public Global Stellar Network ; September 2015") +STELLAR_KEY_NAME := env_var_or_default("STELLAR_KEY_NAME", "templar-hot-mainnet") +HOT_OMNI_NEAR_RPC_URL := env_var_or_default("HOT_OMNI_NEAR_RPC_URL", "https://rpc.mainnet.near.org") _default: @just --list @@ -18,14 +23,24 @@ help: @echo " CURATOR_ACCOUNT"; @echo " NEAR_MARKET"; @echo " STELLAR_RECEIVER"; + @echo " HOT_STELLAR_RECEIVER_HEX"; @echo " OMNI_TOKEN_ID"; @echo " FUNDER_ACCOUNT (for fund/smoke)"; - @echo " WITHDRAW_LIQUIDITY_ACK=YES (required for withdraw)"; + @echo " WITHDRAW_LIQUIDITY_ACK=YES (required for Stellar withdraw)"; + @echo " STELLAR_KEY_NAME or STELLAR_SECRET_KEY (for stellar-deposit)"; @echo "Optional:"; @echo " NETWORK_ID (default: mainnet)"; @echo " OMNI_CONTRACT (default: v2_1.omni.hot.tg)"; @echo " STORAGE_DEPOSIT_NEAR (default: 0.25)"; - @echo " CALL_GAS (default: 100000000000000)"; + @echo " CALL_GAS (default: 300000000000000)"; + @echo " HOT_STELLAR_LOCKER_CONTRACT (default: mainnet HOT locker)"; + @echo " STELLAR_RPC_URL (default: https://mainnet.sorobanrpc.com)"; + @echo " STELLAR_NETWORK_PASSPHRASE (default: public mainnet)"; + @echo " STELLAR_KEY_NAME (default: templar-hot-mainnet)"; + @echo " HOT_STELLAR_ENCODED_RECEIVER (optional for monitor-withdrawal)"; + @echo; + @echo "Load STELLAR_SECRET_KEY from Stellar keystore when needed:"; + @echo " source contract/vault-counterparty/scripts/load-stellar-secret-env.sh"; @echo; @echo "Mainnet flow:"; @echo " 1) just bootstrap"; @@ -39,12 +54,21 @@ check-tools: command -v cargo >/dev/null command -v near >/dev/null +check-stellar-tools: + command -v stellar >/dev/null + command -v python3 >/dev/null + +check-monitor-tools: + command -v node >/dev/null + command -v python3 >/dev/null + check-env: : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" : "${OWNER_ACCOUNT:?set OWNER_ACCOUNT}" : "${CURATOR_ACCOUNT:?set CURATOR_ACCOUNT}" : "${NEAR_MARKET:?set NEAR_MARKET}" : "${STELLAR_RECEIVER:?set STELLAR_RECEIVER}" + : "${HOT_STELLAR_RECEIVER_HEX:?set HOT_STELLAR_RECEIVER_HEX}" : "${OMNI_TOKEN_ID:?set OMNI_TOKEN_ID}" build: check-tools @@ -56,7 +80,7 @@ deploy: check-tools near deploy --accountId "$COUNTERPARTY_ACCOUNT" --wasmFile "{{WASM_PATH}}" --networkId "{{NETWORK_ID}}" init: check-env - ARGS='{"stellar_receiver":"'"$STELLAR_RECEIVER"'","near_market":"'"$NEAR_MARKET"'","omni_token_id":"'"$OMNI_TOKEN_ID"'","omni_contract":"'"{{OMNI_CONTRACT}}"'","curator":"'"$CURATOR_ACCOUNT"'","owner":"'"$OWNER_ACCOUNT"'"}' + ARGS='{"stellar_receiver":"'"$STELLAR_RECEIVER"'","near_market":"'"$NEAR_MARKET"'","omni_token_id":"'"$OMNI_TOKEN_ID"'","omni_contract":"'"{{OMNI_CONTRACT}}"'","hot_deposit_receiver_hex":"'"$HOT_STELLAR_RECEIVER_HEX"'","curator":"'"$CURATOR_ACCOUNT"'","owner":"'"$OWNER_ACCOUNT"'"}' near call "$COUNTERPARTY_ACCOUNT" new "$ARGS" --accountId "$OWNER_ACCOUNT" --networkId "{{NETWORK_ID}}" config: @@ -87,6 +111,10 @@ bridge-preflight: check-env @echo " just balance $COUNTERPARTY_ACCOUNT" @echo " - Then set:" @echo " export WITHDRAW_LIQUIDITY_ACK=YES" + @echo " - If assets were forwarded to market, run:" + @echo " just request-market-withdraw " + @echo " just execute-market-withdraw" + @echo " before withdrawing the returned counterparty balance to Stellar." fund amount='1000': : "${FUNDER_ACCOUNT:?set FUNDER_ACCOUNT}" @@ -95,11 +123,55 @@ fund amount='1000': ARGS='{"receiver_id":"'"$COUNTERPARTY_ACCOUNT"'","token_id":"'"$OMNI_TOKEN_ID"'","amount":"{{amount}}"}' near call "{{OMNI_CONTRACT}}" mt_transfer "$ARGS" --depositYocto 1 --accountId "$FUNDER_ACCOUNT" --networkId "{{NETWORK_ID}}" --gas "{{CALL_GAS}}" +stellar-deposit amount='1000000' asset='native' receiver='': check-stellar-tools + : "${HOT_STELLAR_RECEIVER_HEX:?set HOT_STELLAR_RECEIVER_HEX}" + RECEIVER="{{receiver}}"; if [ -z "$RECEIVER" ]; then RECEIVER="${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT or pass receiver}"; fi + if [ "{{asset}}" != "native" ]; then echo "only native XLM is supported by this recipe right now" >&2; exit 1; fi + SOURCE_ACCOUNT="${STELLAR_SECRET_KEY:-{{STELLAR_KEY_NAME}}}" + SENDER_ACCOUNT="${STELLAR_SENDER_ACCOUNT:-$(stellar keys address "{{STELLAR_KEY_NAME}}")}" + TOKEN_CONTRACT="$(stellar contract id asset --asset native --rpc-url "{{STELLAR_RPC_URL}}" --network-passphrase "{{STELLAR_NETWORK_PASSPHRASE}}")" + CLIENT_TIMESTAMP="$(python3 -c 'import time; print(time.time_ns() * 1000 - 20_000_000_000_000)')" + echo "HOT locker contract: {{HOT_STELLAR_LOCKER_CONTRACT}}" + echo "Stellar sender: $SENDER_ACCOUNT" + echo "NEAR receiver: $RECEIVER" + echo "Receiver hash: $HOT_STELLAR_RECEIVER_HEX" + echo "Token contract: $TOKEN_CONTRACT" + echo "Amount: {{amount}}" + echo "Client timestamp: $CLIENT_TIMESTAMP" + stellar contract invoke \ + --id "{{HOT_STELLAR_LOCKER_CONTRACT}}" \ + --source-account "$SOURCE_ACCOUNT" \ + --rpc-url "{{STELLAR_RPC_URL}}" \ + --network-passphrase "{{STELLAR_NETWORK_PASSPHRASE}}" \ + --send=yes \ + -- \ + deposit \ + --sender_id "$SENDER_ACCOUNT" \ + --receiver_id "$HOT_STELLAR_RECEIVER_HEX" \ + --token "$TOKEN_CONTRACT" \ + --client_timestamp "$CLIENT_TIMESTAMP" \ + --amount "{{amount}}" + forward amount='100': : "${CURATOR_ACCOUNT:?set CURATOR_ACCOUNT}" : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" near call "$COUNTERPARTY_ACCOUNT" forward_to_market '{"amount":"{{amount}}"}' --accountId "$CURATOR_ACCOUNT" --networkId "{{NETWORK_ID}}" --gas "{{CALL_GAS}}" +request-market-withdraw amount='50': + : "${CURATOR_ACCOUNT:?set CURATOR_ACCOUNT}" + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + near call "$COUNTERPARTY_ACCOUNT" request_market_withdrawal '{"amount":"{{amount}}"}' --accountId "$CURATOR_ACCOUNT" --networkId "{{NETWORK_ID}}" --gas "{{CALL_GAS}}" + +cancel-market-withdraw: + : "${CURATOR_ACCOUNT:?set CURATOR_ACCOUNT}" + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + near call "$COUNTERPARTY_ACCOUNT" cancel_market_withdrawal '{}' --accountId "$CURATOR_ACCOUNT" --networkId "{{NETWORK_ID}}" --gas "{{CALL_GAS}}" + +execute-market-withdraw batch_limit='1': + : "${CURATOR_ACCOUNT:?set CURATOR_ACCOUNT}" + : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" + near call "$COUNTERPARTY_ACCOUNT" execute_market_withdrawal '{"batch_limit":{{batch_limit}}}' --accountId "$CURATOR_ACCOUNT" --networkId "{{NETWORK_ID}}" --gas "{{CALL_GAS}}" + withdraw amount='50': : "${CURATOR_ACCOUNT:?set CURATOR_ACCOUNT}" : "${COUNTERPARTY_ACCOUNT:?set COUNTERPARTY_ACCOUNT}" @@ -121,25 +193,35 @@ balances: @echo "market balance:"; just balance "$NEAR_MARKET" +monitor-withdrawal nonce receiver='' interval='15': check-monitor-tools + RECEIVER="{{receiver}}"; if [ -z "$RECEIVER" ]; then RECEIVER="${STELLAR_RECEIVER:?set STELLAR_RECEIVER or pass receiver}"; fi + while true; do echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') ====="; echo "nonce: {{nonce}}"; echo "receiver: $RECEIVER"; echo "-- stellar native balance --"; python3 -c 'import json, sys, urllib.request; addr = sys.argv[1]; data = json.load(urllib.request.urlopen(f"https://horizon.stellar.org/accounts/{addr}", timeout=30)); [print(balance.get("balance")) for balance in data.get("balances", []) if balance.get("asset_type") == "native"]' "$RECEIVER" || true; echo "-- hot withdraw/sign --"; node -e 'const nonce = process.argv[1]; for (const url of ["https://rpc1.hotdao.ai/withdraw/sign", "https://rpc2.hotdao.ai/withdraw/sign"]) { try { const res = await fetch(url, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({nonce})}); console.log(url, res.status, await res.text()); } catch (error) { console.log(url, "ERR", String(error)); } }' "{{nonce}}"; echo "-- hot clear_completed_withdrawal --"; node -e 'const nonce = process.argv[1]; const headers = {"omni-version": "v2", "Content-Type": "application/json", "Referer": "https://near-intents.org", "Origin": "https://near-intents.org"}; for (const url of ["https://api0.herewallet.app/api/v1/transactions/clear_completed_withdrawal", "https://api2.herewallet.app/api/v1/transactions/clear_completed_withdrawal"]) { try { const res = await fetch(url, {method: "POST", headers, body: JSON.stringify({nonce})}); console.log(url, res.status, await res.text()); } catch (error) { console.log(url, "ERR", String(error)); } }' "{{nonce}}"; if [ -n "${HOT_STELLAR_ENCODED_RECEIVER:-}" ]; then echo "-- near pending withdrawals --"; python3 -c 'import base64, json, sys, urllib.request; encoded, account_id, rpc_url = sys.argv[1:]; args = {"receiver_id": encoded, "chain_id": 1100}; payload = {"jsonrpc": "2.0", "id": "dontcare", "method": "query", "params": {"request_type": "call_function", "finality": "final", "account_id": account_id, "method_name": "get_withdrawals_by_receiver", "args_base64": base64.b64encode(json.dumps(args).encode()).decode()}}; request = urllib.request.Request(rpc_url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}); print(urllib.request.urlopen(request, timeout=60).read().decode())' "$HOT_STELLAR_ENCODED_RECEIVER" "{{OMNI_CONTRACT}}" "{{HOT_OMNI_NEAR_RPC_URL}}" || true; else echo "-- near pending withdrawals skipped; set HOT_STELLAR_ENCODED_RECEIVER to query by HOT receiver id --"; fi; if [ "${ONCE:-0}" = "1" ]; then break; fi; sleep "{{interval}}"; done + # Fast synthetic smoke path: funds counterparty by direct NEAR mt_transfer. -smoke-synthetic fund_amount='1000' forward_amount='100' withdraw_amount='50': check-env +smoke-synthetic fund_amount='1000' forward_amount='100' market_withdraw_amount='50' stellar_withdraw_amount='50': check-env : "${FUNDER_ACCOUNT:?set FUNDER_ACCOUNT}" just bootstrap just fund "{{fund_amount}}" just balances just forward "{{forward_amount}}" just balances - just withdraw "{{withdraw_amount}}" + just request-market-withdraw "{{market_withdraw_amount}}" + just execute-market-withdraw + just balances + just withdraw "{{stellar_withdraw_amount}}" just balances # Real bridge smoke path: assumes Stellar->HOT locker deposit already happened. -smoke-bridge forward_amount='100' withdraw_amount='50': check-env +smoke-bridge forward_amount='100' market_withdraw_amount='50' stellar_withdraw_amount='50': check-env just bridge-preflight just balances just forward "{{forward_amount}}" just balances - just withdraw "{{withdraw_amount}}" + just request-market-withdraw "{{market_withdraw_amount}}" + just execute-market-withdraw + just balances + just withdraw "{{stellar_withdraw_amount}}" just balances -smoke forward_amount='100' withdraw_amount='50': - just smoke-bridge forward_amount="{{forward_amount}}" withdraw_amount="{{withdraw_amount}}" +smoke forward_amount='100' market_withdraw_amount='50' stellar_withdraw_amount='50': + just smoke-bridge forward_amount="{{forward_amount}}" market_withdraw_amount="{{market_withdraw_amount}}" stellar_withdraw_amount="{{stellar_withdraw_amount}}" diff --git a/contract/vault-counterparty/scripts/load-stellar-secret-env.sh b/contract/vault-counterparty/scripts/load-stellar-secret-env.sh new file mode 100755 index 000000000..ab2ead5ec --- /dev/null +++ b/contract/vault-counterparty/scripts/load-stellar-secret-env.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Load STELLAR_SECRET_KEY from the Stellar CLI keystore into the current shell. +# +# Usage: +# source contract/vault-counterparty/scripts/load-stellar-secret-env.sh [identity] +# +# The secret is exported for child processes but never printed. + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + cat >&2 <<'EOF' +This script must be sourced so it can update your current shell: + + source contract/vault-counterparty/scripts/load-stellar-secret-env.sh [identity] + +The identity defaults to STELLAR_KEY_NAME, then templar-hot-mainnet. +EOF + exit 2 +fi + +set -euo pipefail + +identity="${1:-${STELLAR_KEY_NAME:-templar-hot-mainnet}}" +config_args=() +if [[ -n "${STELLAR_CONFIG_DIR:-}" ]]; then + config_args=(--config-dir "$STELLAR_CONFIG_DIR") +fi + +if ! command -v stellar >/dev/null 2>&1; then + echo "stellar CLI not found" >&2 + return 1 +fi + +secret="$(stellar keys secret "$identity" "${config_args[@]}")" +sender="$(stellar keys address "$identity" "${config_args[@]}")" + +if [[ -z "$secret" || -z "$sender" ]]; then + echo "failed to load Stellar identity: $identity" >&2 + return 1 +fi + +export STELLAR_KEY_NAME="$identity" +export STELLAR_SECRET_KEY="$secret" +export STELLAR_SENDER_ACCOUNT="$sender" + +echo "Loaded STELLAR_SECRET_KEY for Stellar identity '$identity' ($sender)" >&2 diff --git a/contract/vault/soroban/README.md b/contract/vault/soroban/README.md index 704c7855e..8ac250262 100644 --- a/contract/vault/soroban/README.md +++ b/contract/vault/soroban/README.md @@ -298,6 +298,14 @@ Use recipes in [contract/vault/soroban/justfile](./justfile): `HOT_STELLAR_RECEIVER_HEX` is intentionally explicit. It should be copied from a proven HOT receiver for the NEAR counterparty, not recomputed locally during deployment. +`deploy-hot-bridge-adapter` pins the production HOT Stellar locker by contract ID and fetched WASM +SHA-256 before deploying. For non-production testing only, set `HOT_STELLAR_LOCKER_ALLOW_UNPINNED=1` +and override `HOT_STELLAR_LOCKER_EXPECTED_WASM_SHA256` for the test locker. + +Withdrawal settlement is observable on Soroban only when returned Stellar-side token balance reaches +the adapter. Without a NEAR/HOT proof verifier, operators must treat the HOT bridge/relayer path as a +trusted settlement dependency before calling `progress_withdrawal`. + ## Deployment Artifact The Soroban justfile builds two runtime artifacts: diff --git a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs index 0bc5a8428..f1ea1059b 100644 --- a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -10,6 +10,7 @@ use stellar_contract_utils::upgradeable::{self, Upgradeable}; const INSTANCE_TTL_THRESHOLD: u32 = 518_400; const INSTANCE_TTL_EXTEND_TO: u32 = 3_110_400; +const HOT_TIMESTAMP_SCALE: u128 = 1_000_000_000_000; #[contracttype] #[derive(Clone, Debug)] @@ -20,6 +21,7 @@ enum DataKey { HotLocker, HotReceiverId, Paused, + LastHotClientTimestamp, Principal(Address), } @@ -37,6 +39,7 @@ pub enum AdapterError { Paused = 8, InsufficientAdapterBalance = 9, DepositPostconditionFailed = 10, + HotClientTimestampExhausted = 11, } #[contract] @@ -95,7 +98,7 @@ impl HotBridgeAdapterContract { let adapter = env.current_contract_address(); let hot_locker = get_hot_locker(&env)?; let hot_receiver_id = get_hot_receiver_id(&env)?; - let client_timestamp = hot_locker_timestamp(&env)?; + let client_timestamp = next_hot_locker_timestamp(&env)?; let amount_u128 = amount_as_u128(amount)?; let token = soroban_sdk::token::Client::new(&env, &asset); let balance_before = token.balance(&adapter); @@ -124,6 +127,7 @@ impl HotBridgeAdapterContract { return Err(AdapterError::DepositPostconditionFailed); } + record_hot_locker_timestamp(&env, client_timestamp); increase_principal(&env, &asset, amount)?; env.events().publish( (symbol_short!("hot_dep"), asset.clone()), @@ -296,12 +300,39 @@ fn amount_as_u128(amount: i128) -> Result { u128::try_from(amount).map_err(|_| AdapterError::InvalidInput) } -fn hot_locker_timestamp(env: &Env) -> Result { - u128::from(env.ledger().timestamp()) - .checked_mul(1_000_000_000_000) +fn hot_locker_timestamp(ledger_timestamp: u64) -> Result { + u128::from(ledger_timestamp) + .checked_mul(HOT_TIMESTAMP_SCALE) .ok_or(AdapterError::ArithmeticOverflow) } +fn next_hot_locker_timestamp(env: &Env) -> Result { + let base_timestamp = hot_locker_timestamp(env.ledger().timestamp())?; + let last_timestamp = env + .storage() + .instance() + .get::<_, u128>(&DataKey::LastHotClientTimestamp); + let client_timestamp = if let Some(last_timestamp) = last_timestamp { + if base_timestamp <= last_timestamp { + last_timestamp + .checked_sub(1) + .ok_or(AdapterError::HotClientTimestampExhausted)? + } else { + base_timestamp + } + } else { + base_timestamp + }; + + Ok(client_timestamp) +} + +fn record_hot_locker_timestamp(env: &Env, client_timestamp: u128) { + env.storage() + .instance() + .set(&DataKey::LastHotClientTimestamp, &client_timestamp); +} + fn principal_of(env: &Env, asset: &Address) -> i128 { env.storage() .instance() @@ -616,6 +647,55 @@ mod tests { ); } + #[test] + fn supply_uses_unique_hot_nonce_for_same_ledger_allocations() { + let env = Env::default(); + let (adapter, _admin, vault, hot_locker, asset, receiver) = setup(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let asset_admin = StellarAssetClient::new(&env, &asset); + let base_nonce = 1_777_000_000_000_000_000_000; + + asset_admin.mint(&adapter, &100); + client.supply(&vault, &asset, &100); + assert_hot_deposit_event( + &env, + &adapter, + &asset, + &hot_locker, + &receiver, + 100, + base_nonce, + ); + + asset_admin.mint(&adapter, &60); + client.supply(&vault, &asset, &60); + assert_hot_deposit_event( + &env, + &adapter, + &asset, + &hot_locker, + &receiver, + 60, + base_nonce - 1, + ); + assert_eq!(client.total_assets(&asset), 160); + + env.ledger().set_timestamp(1_777_000_001); + asset_admin.mint(&adapter, &25); + client.supply(&vault, &asset, &25); + + assert_hot_deposit_event( + &env, + &adapter, + &asset, + &hot_locker, + &receiver, + 25, + 1_777_000_001_000_000_000_000, + ); + assert_eq!(client.total_assets(&asset), 185); + } + #[test] fn progress_withdrawal_returns_hot_completed_balance_to_vault() { let env = Env::default(); diff --git a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs index 0f267527e..37af1382c 100644 --- a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs +++ b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs @@ -147,6 +147,47 @@ fn mainnet_locker_wasm_accepts_verified_deposit_shape() { assert!(!get_locker_deposit(&env, &locker, nonce).is_empty()); } +#[test] +#[should_panic(expected = "HostError: Error(WasmVm, InvalidAction)")] +fn mainnet_locker_wasm_rejects_duplicate_client_timestamp_in_same_ledger() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_777_000_000); + + let locker = register_mainnet_locker_wasm(&env); + let token_sac = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let token = token_sac.address(); + let token_admin = StellarAssetClient::new(&env, &token); + let first_sender = dummy_contract(&env); + let second_sender = dummy_contract(&env); + let receiver = proven_receiver(&env); + let client_timestamp = 1_777_000_000_000_000_000_000; + token_admin.mint(&first_sender, &100); + token_admin.mint(&second_sender, &60); + + let first_nonce = invoke_locker_deposit( + &env, + &locker, + &first_sender, + 100, + &token, + &receiver, + client_timestamp, + ); + assert_eq!(first_nonce, client_timestamp); + assert!(!get_locker_deposit(&env, &locker, first_nonce).is_empty()); + + let _second_nonce = invoke_locker_deposit( + &env, + &locker, + &second_sender, + 60, + &token, + &receiver, + client_timestamp, + ); +} + #[test] fn adapter_supply_works_against_mainnet_locker_wasm() { let env = Env::default(); @@ -187,3 +228,39 @@ fn adapter_supply_works_against_mainnet_locker_wasm() { 100 ); } + +#[test] +fn adapter_supply_supports_two_deposits_in_same_ledger_against_mainnet_locker_wasm() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_777_000_000); + + let admin = dummy_contract(&env); + let vault = dummy_contract(&env); + let locker = register_mainnet_locker_wasm(&env); + let token_sac = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let token = token_sac.address(); + let receiver = proven_receiver(&env); + let adapter = env.register( + HotBridgeAdapterContract, + (&admin, &vault, &locker, &receiver), + ); + let adapter_client = HotBridgeAdapterContractClient::new(&env, &adapter); + StellarAssetClient::new(&env, &token).mint(&adapter, &100); + adapter_client.supply(&vault, &token, &100); + + let first_nonce = 1_777_000_000_000_000_000_000; + let second_nonce = first_nonce - 1; + assert_hot_deposit_event(&env, &adapter, &token, &locker, &receiver, 100, first_nonce); + + StellarAssetClient::new(&env, &token).mint(&adapter, &60); + adapter_client.supply(&vault, &token, &60); + assert_hot_deposit_event(&env, &adapter, &token, &locker, &receiver, 60, second_nonce); + assert!(!get_locker_deposit(&env, &locker, first_nonce).is_empty()); + assert!(!get_locker_deposit(&env, &locker, second_nonce).is_empty()); + assert_eq!(adapter_client.total_assets(&token), 160); + assert_eq!( + soroban_sdk::token::Client::new(&env, &token).balance(&locker), + 160 + ); +} diff --git a/contract/vault/soroban/justfile b/contract/vault/soroban/justfile index 16d7daef9..03cdbd05e 100644 --- a/contract/vault/soroban/justfile +++ b/contract/vault/soroban/justfile @@ -37,6 +37,9 @@ set dotenv-load := false # HOT_BRIDGE_ADAPTER_ID Deployed HOT bridge adapter contract ID # HOT_STELLAR_LOCKER_CONTRACT HOT Stellar locker contract ID # HOT_STELLAR_RECEIVER_HEX Proven HOT receiver bytes for the NEAR counterparty +# HOT_STELLAR_LOCKER_EXPECTED_CONTRACT Expected pinned HOT locker contract ID +# HOT_STELLAR_LOCKER_EXPECTED_WASM_SHA256 Expected pinned HOT locker WASM SHA-256 +# HOT_STELLAR_LOCKER_ALLOW_UNPINNED=1 Skip pinned locker contract-ID check # SOROBAN_ADAPTER_ADMIN Blend adapter admin contract (default: governance, then vault) # # ============================================================================= @@ -59,6 +62,8 @@ default_friendbot := "https://friendbot.stellar.org" default_network := "testnet" default_rpc := "https://soroban-testnet.stellar.org" default_identity := "templar" +hot_stellar_locker_expected_contract := "CCLWL5NYSV2WJQ3VBU44AMDHEVKEPA45N2QP2LL62O3JVKPGWWAQUVAG" +hot_stellar_locker_expected_wasm_sha256 := "cc74acb56fd01ef560b5d443cd58fe11d5acbece0e4001612ebb31020fc92f97" # Path for storing deployment state state_dir := justfile_directory() + "/.deploy-state" @@ -578,16 +583,42 @@ deploy-hot-bridge-adapter: fi; \ hot_locker="${HOT_STELLAR_LOCKER_CONTRACT:?set HOT_STELLAR_LOCKER_CONTRACT}"; \ hot_receiver_id="${HOT_STELLAR_RECEIVER_HEX:?set HOT_STELLAR_RECEIVER_HEX to the proven HOT receiver bytes}"; \ + expected_locker="${HOT_STELLAR_LOCKER_EXPECTED_CONTRACT:-{{hot_stellar_locker_expected_contract}}}"; \ + expected_hash="${HOT_STELLAR_LOCKER_EXPECTED_WASM_SHA256:-{{hot_stellar_locker_expected_wasm_sha256}}}"; \ + if [ "${HOT_STELLAR_LOCKER_ALLOW_UNPINNED:-0}" != "1" ] && [ "$hot_locker" != "$expected_locker" ]; then \ + echo "Error: HOT_STELLAR_LOCKER_CONTRACT does not match pinned mainnet locker"; \ + echo " expected: $expected_locker"; \ + echo " actual: $hot_locker"; \ + echo "Set HOT_STELLAR_LOCKER_ALLOW_UNPINNED=1 only for non-production testing."; \ + exit 1; \ + fi; \ if ! printf '%s' "$hot_receiver_id" | grep -Eq '^[0-9A-Fa-f]{64}$'; then \ echo "Error: HOT_STELLAR_RECEIVER_HEX must be exactly 64 hex characters"; \ exit 1; \ fi; \ network="${SOROBAN_NETWORK:-{{default_network}}}"; \ source="${SOROBAN_IDENTITY:-{{default_identity}}}"; \ + tmp_wasm="$(mktemp "${TMPDIR:-/data/tmp}/hot-stellar-locker.XXXXXX.wasm")"; \ + trap 'rm -f "$tmp_wasm"' EXIT; \ + echo "Verifying pinned HOT locker WASM..."; \ + if [ -n "${SOROBAN_RPC_URL:-}" ]; then \ + passphrase="${SOROBAN_NETWORK_PASSPHRASE:?set SOROBAN_NETWORK_PASSPHRASE when SOROBAN_RPC_URL is set}"; \ + stellar contract fetch --id "$hot_locker" --rpc-url "$SOROBAN_RPC_URL" --network-passphrase "$passphrase" --out-file "$tmp_wasm"; \ + else \ + stellar contract fetch --id "$hot_locker" --network "$network" --out-file "$tmp_wasm"; \ + fi; \ + actual_hash="$(sha256sum "$tmp_wasm" | awk '{print $1}')"; \ + if [ "$actual_hash" != "$expected_hash" ]; then \ + echo "Error: HOT locker WASM hash mismatch"; \ + echo " expected: $expected_hash"; \ + echo " actual: $actual_hash"; \ + exit 1; \ + fi; \ echo "Deploying HOT bridge adapter..."; \ echo " Admin: $admin"; \ echo " Vault: $vault_id"; \ echo " HOT locker: $hot_locker"; \ + echo " Locker hash: $actual_hash"; \ echo " HOT receiver: $hot_receiver_id"; \ adapter_id=$(stellar contract deploy \ --wasm "$wasm" \ From 13bd59b5fd48ba9bda810c77ffc167efbc4f9857 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 4 Jun 2026 11:36:15 +0200 Subject: [PATCH 11/18] docs(soroban): describe market adapter approaches --- contract/vault/soroban/README.md | 66 ++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/contract/vault/soroban/README.md b/contract/vault/soroban/README.md index 8ac250262..70a6ea0ad 100644 --- a/contract/vault/soroban/README.md +++ b/contract/vault/soroban/README.md @@ -283,6 +283,72 @@ Use recipes in [contract/vault/soroban/justfile](./justfile): After deployment, register the adapter as a vault market before allocation. +## Market Adapter Approaches + +Vault allocations into Templar-specific markets can use either a custodial forwarding adapter or the +onchain HOT bridge adapter. + +### Custodial / Multisig Adapter + +A forwarding adapter sends allocated assets to a multisig or address controlled by the operator. The +operator then handles the HOT/Intents bridge and Templar market deposit offchain. + +Supply flow: + +```text +allocate +-> market adapter +-> operator multisig/address +-> operator infra bridges/deposits via HOT / Intents +-> Templar market +``` + +Withdrawal flow: + +```text +operator infra exits/unwinds the Templar market position offchain +-> operator bridges/returns funds back to the adapter +-> funds sit as idle liquidity in the adapter +-> vault withdraws idle liquidity from the adapter +``` + +In this model, a vault withdrawal only withdraws idle liquidity already present in the adapter. The +operator is responsible for making that liquidity available first by unwinding the market position and +returning funds. There is no automatic onchain market-exit step in the adapter. + +### Onchain HOT Bridge Adapter + +The HOT bridge adapter automatically routes the vault allocation through HOT. The bridged assets land +in the Templar counterparty contract, and the counterparty forwards into the configured Templar +market. + +Supply flow: + +```text +allocate +-> HOT bridge adapter +-> HOT / Intents bridge +-> Templar counterparty +-> Templar market +``` + +Withdrawal flow: + +```text +vault requests withdrawal from HOT bridge adapter +-> Templar counterparty exits/advances the market withdrawal flow +-> counterparty receives the withdrawn Intents/HOT asset +-> counterparty initiates HOT withdrawal back to Stellar +-> HOT bridge returns funds to the Stellar-side adapter +-> adapter releases returned funds back to the vault +``` + +This is more automated, but the withdrawal path has more moving parts. The Soroban adapter can +observe funds once they return on Stellar, but it cannot cryptographically prove the NEAR-side +settlement caused that return. This uses the same broad HOT/Intents bridge trust model already used +for Stellar deposits into Templar markets; the difference is whether operator infra or the onchain +adapter/counterparty owns routing and market-forwarding/withdrawal steps. + ## HOT Bridge Adapter HOT bridge integration lives in `contract/vault/soroban/hot-bridge-adapter`. It exposes the same From 6d17b375e4c584d756f2b0368b3e638782bfe9c1 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 4 Jun 2026 12:19:22 +0200 Subject: [PATCH 12/18] test: fuzz and verify HOT bridge contracts --- Cargo.lock | 2 + contract/vault-counterparty/Cargo.toml | 1 + contract/vault-counterparty/src/lib.rs | 773 +++++++++++++++++- .../soroban/hot-bridge-adapter/Cargo.toml | 4 + .../soroban/hot-bridge-adapter/src/lib.rs | 374 ++++++++- 5 files changed, 1120 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47ba1a502..527682bf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13129,6 +13129,7 @@ dependencies = [ name = "templar-soroban-hot-bridge-adapter" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", "stellar-contract-utils", ] @@ -13286,6 +13287,7 @@ version = "0.1.0" dependencies = [ "bs58 0.5.1", "near-sdk", + "proptest", "stellar-xdr 24.0.1", ] diff --git a/contract/vault-counterparty/Cargo.toml b/contract/vault-counterparty/Cargo.toml index 0e437c3ff..f4d5b5e14 100644 --- a/contract/vault-counterparty/Cargo.toml +++ b/contract/vault-counterparty/Cargo.toml @@ -15,6 +15,7 @@ stellar-xdr = { version = "24.0", default-features = false, features = ["curr", [dev-dependencies] near-sdk = { workspace = true, features = ["unit-testing"] } +proptest = "1.4" [lints] workspace = true diff --git a/contract/vault-counterparty/src/lib.rs b/contract/vault-counterparty/src/lib.rs index 436626d01..13063afc6 100644 --- a/contract/vault-counterparty/src/lib.rs +++ b/contract/vault-counterparty/src/lib.rs @@ -5,19 +5,26 @@ use near_sdk::{ json_types::U128, near, require, serde_json::{self, json}, - AccountId, Gas, NearToken, PanicOnDefault, Promise, + AccountId, Gas, NearToken, PanicOnDefault, Promise, PromiseOrValue, }; +use std::collections::BTreeMap; use std::str::FromStr; use stellar_xdr::curr::{Limited, Limits, ScAddress, ScVal, WriteXdr}; const ONE_YOCTO: NearToken = NearToken::from_yoctonear(1); -const GAS_MT_TRANSFER: Gas = Gas::from_tgas(50); +const NO_DEPOSIT: NearToken = NearToken::from_yoctonear(0); +const GAS_MT_TRANSFER_CALL: Gas = Gas::from_tgas(150); +const GAS_MARKET_WITHDRAWAL_REQUEST: Gas = Gas::from_tgas(50); +const GAS_MARKET_WITHDRAWAL_EXECUTE: Gas = Gas::from_tgas(100); +const GAS_INTENTS_MT_TRANSFER: Gas = Gas::from_tgas(50); const GAS_WITHDRAW_TARGET: Gas = Gas::from_tgas(250); const GAS_WITHDRAW_BUFFER: Gas = Gas::from_tgas(20); const INTENTS_CONTRACT: &str = "intents.near"; const BRIDGE_REFUEL_ACCOUNT: &str = "bridge-refuel.hot.tg"; +const MARKET_SUPPLY_MSG: &str = "\"Supply\""; const MAX_STELLAR_RECEIVER_LEN: usize = 256; const MAX_TOKEN_ID_LEN: usize = 256; +const HOT_DEPOSIT_RECEIVER_HEX_LEN: usize = 64; const HOT_STELLAR_CHAIN_PREFIX: &str = "1100_"; #[derive(Debug, Clone)] @@ -30,6 +37,7 @@ pub struct Config { pub owner: AccountId, pub pending_owner: Option, pub omni_contract: AccountId, + pub hot_deposit_receiver_hex: String, } #[derive(Debug, Clone)] @@ -109,6 +117,28 @@ impl OmniTokenId { } } +#[derive(Debug, Clone)] +#[near(serializers = [borsh])] +struct HotDepositReceiver { + hex: String, +} + +impl HotDepositReceiver { + fn new(receiver_hex: String) -> Self { + require!( + receiver_hex.len() == HOT_DEPOSIT_RECEIVER_HEX_LEN + && receiver_hex.bytes().all(|b| b.is_ascii_hexdigit()), + "hot deposit receiver must be 64 hex characters" + ); + + Self { hex: receiver_hex } + } + + fn as_str(&self) -> &str { + &self.hex + } +} + #[derive(PanicOnDefault)] #[near(contract_state)] pub struct Contract { @@ -119,6 +149,9 @@ pub struct Contract { owner: AccountId, pending_owner: Option, omni_contract: AccountId, + hot_deposit_receiver: HotDepositReceiver, + supply_positions: BTreeMap, + withdrawal_requests: BTreeMap, } #[near] @@ -129,11 +162,13 @@ impl Contract { near_market: AccountId, omni_token_id: String, omni_contract: AccountId, + hot_deposit_receiver_hex: String, curator: AccountId, owner: AccountId, ) -> Self { let stellar_receiver = StellarReceiver::new(stellar_receiver); let omni_token_id = OmniTokenId::new(omni_token_id, &omni_contract); + let hot_deposit_receiver = HotDepositReceiver::new(hot_deposit_receiver_hex); Self { stellar_receiver, @@ -143,6 +178,9 @@ impl Contract { owner, pending_owner: None, omni_contract, + hot_deposit_receiver, + supply_positions: BTreeMap::new(), + withdrawal_requests: BTreeMap::new(), } } @@ -151,20 +189,153 @@ impl Contract { Self::assert_amount(amount); self.call_omni( - "mt_transfer", + "mt_transfer_call", json!({ "receiver_id": self.near_market, "token_id": self.omni_token_id_for_contract(), "amount": amount, + "msg": MARKET_SUPPLY_MSG, + }), + GAS_MT_TRANSFER_CALL, + ) + } + + pub fn request_market_withdrawal(&self, amount: U128) -> Promise { + self.assert_curator(); + Self::assert_amount(amount); + + self.call_market( + "create_supply_withdrawal_request", + json!({ + "amount": amount, + }), + GAS_MARKET_WITHDRAWAL_REQUEST, + ) + } + + pub fn cancel_market_withdrawal(&self) -> Promise { + self.assert_curator(); + + self.call_market( + "cancel_supply_withdrawal_request", + json!({}), + GAS_MARKET_WITHDRAWAL_REQUEST, + ) + } + + pub fn execute_market_withdrawal(&self, batch_limit: Option) -> Promise { + self.assert_curator(); + + self.call_market( + "execute_next_supply_withdrawal_request", + json!({ + "batch_limit": batch_limit, }), - GAS_MT_TRANSFER, + GAS_MARKET_WITHDRAWAL_EXECUTE, ) } + pub fn get_supply_position(&self, account_id: AccountId) -> Option { + self.supply_positions + .get(&account_id) + .copied() + .filter(|amount| *amount > 0) + .map(Self::supply_position_json) + } + + pub fn create_supply_withdrawal_request(&mut self, amount: U128) { + Self::assert_amount(amount); + let predecessor = env::predecessor_account_id(); + let principal = self + .supply_positions + .get(&predecessor) + .copied() + .unwrap_or(0); + require!( + principal >= amount.0, + "Attempt to withdraw more than current deposit" + ); + self.withdrawal_requests.insert(predecessor, amount.0); + } + + pub fn cancel_supply_withdrawal_request(&mut self) { + self.withdrawal_requests + .remove(&env::predecessor_account_id()); + } + + pub fn execute_next_supply_withdrawal_request( + &mut self, + _batch_limit: Option, + ) -> PromiseOrValue { + let Some((account_id, requested_amount)) = self + .withdrawal_requests + .iter() + .next() + .map(|(account_id, amount)| (account_id.clone(), *amount)) + else { + return PromiseOrValue::Value(Self::withdrawal_execution_result_json(0, 0)); + }; + + let principal = self.supply_positions.get(&account_id).copied().unwrap_or(0); + let amount = requested_amount.min(principal); + if amount == 0 { + self.withdrawal_requests.remove(&account_id); + return PromiseOrValue::Value(Self::withdrawal_execution_result_json(0, 0)); + } + + if requested_amount == amount { + self.withdrawal_requests.remove(&account_id); + } else { + self.withdrawal_requests + .insert(account_id.clone(), requested_amount - amount); + } + self.decrease_supply_position(&account_id, amount); + + PromiseOrValue::Promise(self.intents_transfer_promise(account_id, U128(amount))) + } + pub fn withdraw_to_stellar(&self, amount: U128) -> Promise { self.assert_curator(); Self::assert_amount(amount); + self.hot_withdraw_promise(amount) + } + + pub fn mt_on_transfer( + &mut self, + sender_id: AccountId, + previous_owner_ids: Vec, + token_ids: Vec, + amounts: Vec, + msg: String, + ) -> PromiseOrValue> { + let _authorized_sender = sender_id; + + require!( + env::predecessor_account_id() == account_id(INTENTS_CONTRACT), + "Only Intents can transfer-call this adapter" + ); + require!( + previous_owner_ids.len() == 1 && token_ids.len() == 1 && amounts.len() == 1, + "Invalid input length" + ); + require!( + token_ids[0] == self.intents_wrapped_token_id(), + "Unsupported Intents token" + ); + require!(Self::is_supply_msg(&msg), "Invalid deposit msg"); + + let amount = amounts[0]; + Self::assert_amount(amount); + + let supplier = previous_owner_ids[0].clone(); + self.increase_supply_position(supplier, amount.0); + self.hot_withdraw_promise(amount).detach(); + + PromiseOrValue::Value(vec![U128(0)]) + } + + fn hot_withdraw_promise(&self, amount: U128) -> Promise { let remaining_gas = Gas::from_gas( env::prepaid_gas() .as_gas() @@ -206,6 +377,14 @@ impl Contract { self.omni_token_id_for_contract() } + fn intents_wrapped_token_id(&self) -> String { + format!( + "nep245:{}:{}", + self.omni_contract, + self.omni_token_id_for_contract() + ) + } + fn call_omni(&self, method_name: &str, args: serde_json::Value, gas: Gas) -> Promise { Promise::new(self.omni_contract.clone()).function_call( method_name.to_string(), @@ -216,10 +395,80 @@ impl Contract { ) } + fn call_market(&self, method_name: &str, args: serde_json::Value, gas: Gas) -> Promise { + Promise::new(self.near_market.clone()).function_call( + method_name.to_string(), + serde_json::to_vec(&args) + .unwrap_or_else(|_| env::panic_str("failed to serialize market call args")), + NO_DEPOSIT, + gas, + ) + } + + fn intents_transfer_promise(&self, receiver_id: AccountId, amount: U128) -> Promise { + Promise::new(account_id(INTENTS_CONTRACT)).function_call( + "mt_transfer".to_string(), + serde_json::to_vec(&json!({ + "receiver_id": receiver_id, + "token_id": self.intents_wrapped_token_id(), + "amount": amount, + })) + .unwrap_or_else(|_| env::panic_str("failed to serialize intents transfer args")), + ONE_YOCTO, + GAS_INTENTS_MT_TRANSFER, + ) + } + fn omni_token_id_for_contract(&self) -> String { self.omni_token_id.as_str().to_string() } + fn increase_supply_position(&mut self, account_id: AccountId, amount: u128) { + let current = self.supply_positions.get(&account_id).copied().unwrap_or(0); + let updated = current + .checked_add(amount) + .unwrap_or_else(|| env::panic_str("supply position overflow")); + self.supply_positions.insert(account_id, updated); + } + + fn decrease_supply_position(&mut self, account_id: &AccountId, amount: u128) { + let current = self.supply_positions.get(account_id).copied().unwrap_or(0); + require!(current >= amount, "Insufficient adapter principal"); + let updated = current - amount; + if updated == 0 { + self.supply_positions.remove(account_id); + } else { + self.supply_positions.insert(account_id.clone(), updated); + } + } + + fn supply_position_json(amount: u128) -> serde_json::Value { + json!({ + "started_at_block_timestamp_ms": "0", + "borrow_asset_deposit": { + "active": amount.to_string(), + "incoming": [], + "outgoing": "0", + }, + "borrow_asset_yield": { + "total": "0", + "fraction_as_u128_dividend": "0", + "next_snapshot_index": 0, + }, + }) + } + + fn withdrawal_execution_result_json(depth: u128, length: u32) -> serde_json::Value { + json!({ + "depth": depth.to_string(), + "length": length, + }) + } + + fn is_supply_msg(msg: &str) -> bool { + near_sdk::serde_json::from_str::(msg).is_ok_and(|parsed| parsed == "Supply") + } + fn encode_stellar_sc_address(sc_address: &ScAddress) -> String { let sc_val = ScVal::Address(sc_address.clone()); let mut xdr_bytes = Vec::new(); @@ -274,6 +523,7 @@ impl Contract { owner: self.owner.clone(), pending_owner: self.pending_owner.clone(), omni_contract: self.omni_contract.clone(), + hot_deposit_receiver_hex: self.hot_deposit_receiver.as_str().to_string(), } } @@ -304,9 +554,13 @@ mod tests { test_utils::{get_created_receipts, VMContextBuilder}, testing_env, AccountId, }; + use proptest::prelude::*; use super::*; + const HOT_DEPOSIT_RECEIVER_HEX: &str = + "52fd581de41f4bace88c936b89bf267a1161426a466adc518cd9e56f201651dd"; + fn account(account_id: &str) -> AccountId { account_id .parse() @@ -328,6 +582,7 @@ mod tests { account("templar-market.near"), "1100_stellar_usdc".to_string(), account("v2_1.omni.hot.tg"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), account("curator.near"), account("owner.near"), ) @@ -363,21 +618,487 @@ mod tests { ) } + fn active_position(contract: &Contract, account_id: &AccountId) -> u128 { + contract + .supply_positions + .get(account_id) + .copied() + .unwrap_or(0) + } + + fn catch_contract_panic(call: impl FnOnce()) -> bool { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(call)).is_err() + } + + fn expected_normalized_token_id(input: &str, omni_contract: &AccountId) -> Option { + if input.trim().is_empty() || input.len() > MAX_TOKEN_ID_LEN { + return None; + } + let wrapped_prefix = format!("nep245:{}:", omni_contract); + let normalized = input.strip_prefix(&wrapped_prefix).unwrap_or(input); + if normalized.contains(':') + || !normalized.starts_with(HOT_STELLAR_CHAIN_PREFIX) + || normalized.len() <= HOT_STELLAR_CHAIN_PREFIX.len() + { + return None; + } + Some(normalized.to_string()) + } + + fn token_suffix_strategy() -> impl Strategy { + "[A-Za-z0-9_.-]{1,96}" + } + + #[derive(Clone, Debug)] + enum CounterpartyOp { + Deposit(u128), + Request(u128), + Execute, + Cancel, + } + + fn counterparty_op_strategy() -> impl Strategy { + prop_oneof![ + (1u128..=1_000_000u128).prop_map(CounterpartyOp::Deposit), + (1u128..=1_500_000u128).prop_map(CounterpartyOp::Request), + Just(CounterpartyOp::Execute), + Just(CounterpartyOp::Cancel), + ] + } + + proptest! { + #[test] + fn prop_token_id_config_accepts_only_normalized_hot_stellar_ids( + token_id in ".{0,320}", + use_matching_wrapper in any::(), + ) { + let omni_contract = account("v2_1.omni.hot.tg"); + let configured = if use_matching_wrapper + && !token_id.starts_with("nep245:") + && token_id.len() <= MAX_TOKEN_ID_LEN.saturating_sub("nep245:v2_1.omni.hot.tg:".len()) + { + format!("nep245:{}:{token_id}", omni_contract) + } else { + token_id + }; + let expected = expected_normalized_token_id(&configured, &omni_contract); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Contract::new( + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + account("templar-market.near"), + configured.clone(), + omni_contract.clone(), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), + account("curator.near"), + account("owner.near"), + ) + })); + + match expected { + Some(normalized) => { + let contract = result.expect("valid HOT Stellar token id should initialize"); + prop_assert_eq!(contract.get_config().omni_token_id, normalized); + prop_assert_eq!( + contract.intents_wrapped_token_id(), + format!("nep245:{}:{}", omni_contract, contract.get_config().omni_token_id) + ); + } + None => { + prop_assert!(result.is_err(), "invalid token id was accepted: {configured:?}"); + } + } + } + + #[test] + fn prop_matching_wrapped_token_ids_roundtrip_to_intents_token(suffix in token_suffix_strategy()) { + let raw = format!("{HOT_STELLAR_CHAIN_PREFIX}{suffix}"); + let wrapped = format!("nep245:v2_1.omni.hot.tg:{raw}"); + + let raw_contract = Contract::new( + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + account("templar-market.near"), + raw.clone(), + account("v2_1.omni.hot.tg"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), + account("curator.near"), + account("owner.near"), + ); + let wrapped_contract = Contract::new( + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + account("templar-market.near"), + wrapped, + account("v2_1.omni.hot.tg"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), + account("curator.near"), + account("owner.near"), + ); + + prop_assert_eq!(raw_contract.get_config().omni_token_id, raw.clone()); + prop_assert_eq!(wrapped_contract.get_config().omni_token_id, raw.clone()); + prop_assert_eq!( + wrapped_contract.intents_wrapped_token_id(), + format!("nep245:v2_1.omni.hot.tg:{raw}") + ); + } + + #[test] + fn prop_hot_deposit_receiver_accepts_only_64_hex_chars( + candidate in "[0-9A-Fa-f]{0,80}", + append_non_hex in any::(), + ) { + let configured = if append_non_hex { + format!("{candidate}g") + } else { + candidate + }; + let should_accept = configured.len() == HOT_DEPOSIT_RECEIVER_HEX_LEN + && configured.bytes().all(|b| b.is_ascii_hexdigit()); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Contract::new( + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + account("templar-market.near"), + "1100_stellar_usdc".to_string(), + account("v2_1.omni.hot.tg"), + configured.clone(), + account("curator.near"), + account("owner.near"), + ) + })); + + if should_accept { + prop_assert!(result.is_ok()); + } else { + prop_assert!(result.is_err(), "invalid HOT receiver was accepted: {configured:?}"); + } + } + + #[test] + fn prop_mt_on_transfer_validation_is_atomic( + valid_predecessor in any::(), + valid_lengths in any::(), + valid_token in any::(), + valid_msg in any::(), + amount in 0u128..=1_000_000u128, + ) { + let mut contract = test_contract(); + let supplier = account("vault.near"); + let predecessor = if valid_predecessor { + account(INTENTS_CONTRACT) + } else { + account("attacker.near") + }; + context(&predecessor); + + let previous_owner_ids = if valid_lengths { + vec![supplier.clone()] + } else { + vec![supplier.clone(), account("other.near")] + }; + let token_ids = if valid_lengths { + vec![if valid_token { + "nep245:v2_1.omni.hot.tg:1100_stellar_usdc".to_string() + } else { + "1100_stellar_usdc".to_string() + }] + } else { + vec![] + }; + let amounts = if valid_lengths { + vec![U128(amount)] + } else { + vec![U128(amount), U128(1)] + }; + let msg = if valid_msg { + MARKET_SUPPLY_MSG.to_string() + } else { + "\"Withdraw\"".to_string() + }; + let before = active_position(&contract, &supplier); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + contract.mt_on_transfer( + account(INTENTS_CONTRACT), + previous_owner_ids, + token_ids, + amounts, + msg, + ) + })); + + let should_succeed = + valid_predecessor && valid_lengths && valid_token && valid_msg && amount > 0; + if should_succeed { + let refunds = result.expect("valid transfer-call should succeed"); + prop_assert!(matches!(refunds, PromiseOrValue::Value(ref values) if values == &vec![U128(0)])); + prop_assert_eq!(active_position(&contract, &supplier), before + amount); + } else { + prop_assert!(result.is_err(), "invalid transfer-call unexpectedly succeeded"); + prop_assert_eq!(active_position(&contract, &supplier), before); + } + } + + #[test] + fn prop_supply_withdrawal_queue_matches_simple_model( + ops in prop::collection::vec(counterparty_op_strategy(), 1..25), + ) { + let mut contract = test_contract(); + let supplier = account("vault.near"); + let mut model_principal = 0u128; + let mut model_request: Option = None; + + for op in ops { + match op { + CounterpartyOp::Deposit(amount) => { + context(&account(INTENTS_CONTRACT)); + let result = contract.mt_on_transfer( + account(INTENTS_CONTRACT), + vec![supplier.clone()], + vec!["nep245:v2_1.omni.hot.tg:1100_stellar_usdc".to_string()], + vec![U128(amount)], + MARKET_SUPPLY_MSG.to_string(), + ); + prop_assert!(matches!(result, PromiseOrValue::Value(ref refunds) if refunds == &vec![U128(0)])); + model_principal = model_principal.checked_add(amount).expect("bounded test amounts do not overflow"); + } + CounterpartyOp::Request(amount) => { + if model_principal == 0 { + continue; + } + let amount = (amount % model_principal) + 1; + context(&supplier); + contract.create_supply_withdrawal_request(U128(amount)); + model_request = Some(amount); + } + CounterpartyOp::Execute => { + context(&account("keeper.near")); + let result = contract.execute_next_supply_withdrawal_request(Some(1)); + match model_request { + Some(requested) => { + let executed = requested.min(model_principal); + if executed == 0 { + prop_assert!(matches!(result, PromiseOrValue::Value(_))); + model_request = None; + } else { + prop_assert!(matches!(result, PromiseOrValue::Promise(_))); + model_principal -= executed; + model_request = requested.checked_sub(executed).filter(|remaining| *remaining > 0); + } + } + None => { + prop_assert!(matches!(result, PromiseOrValue::Value(_))); + } + } + } + CounterpartyOp::Cancel => { + context(&supplier); + contract.cancel_supply_withdrawal_request(); + model_request = None; + } + } + + prop_assert_eq!(active_position(&contract, &supplier), model_principal); + prop_assert_eq!(contract.withdrawal_requests.get(&supplier).copied(), model_request); + } + } + + #[test] + fn prop_invalid_supply_withdrawal_request_does_not_mutate( + principal in 0u128..=1_000_000u128, + extra in 0u128..=1_000_000u128, + use_zero_request in any::(), + ) { + let mut contract = test_contract(); + let supplier = account("vault.near"); + if principal > 0 { + context(&account(INTENTS_CONTRACT)); + let _ = contract.mt_on_transfer( + account(INTENTS_CONTRACT), + vec![supplier.clone()], + vec!["nep245:v2_1.omni.hot.tg:1100_stellar_usdc".to_string()], + vec![U128(principal)], + MARKET_SUPPLY_MSG.to_string(), + ); + } + + context(&supplier); + let request = if use_zero_request { + 0 + } else { + principal.saturating_add(extra).saturating_add(1) + }; + let before_principal = active_position(&contract, &supplier); + let before_request = contract.withdrawal_requests.get(&supplier).copied(); + + let panicked = catch_contract_panic(|| { + contract.create_supply_withdrawal_request(U128(request)); + }); + + prop_assert!(panicked); + prop_assert_eq!(active_position(&contract, &supplier), before_principal); + prop_assert_eq!(contract.withdrawal_requests.get(&supplier).copied(), before_request); + } + } + #[test] - fn forward_to_market_builds_expected_mt_transfer_call() { + fn forward_to_market_builds_expected_mt_transfer_call_supply() { let contract = test_contract(); context(&account("curator.near")); - contract.forward_to_market(U128(42)); + let _ = contract.forward_to_market(U128(42)); let (receiver, method, args, attached_deposit, prepaid_gas) = first_function_call(); assert_eq!(receiver, account("v2_1.omni.hot.tg")); - assert_eq!(method, "mt_transfer"); + assert_eq!(method, "mt_transfer_call"); assert_eq!(attached_deposit, ONE_YOCTO); - assert_eq!(prepaid_gas, GAS_MT_TRANSFER); + assert_eq!(prepaid_gas, GAS_MT_TRANSFER_CALL); assert_eq!(args["receiver_id"], "templar-market.near"); assert_eq!(args["token_id"], "1100_stellar_usdc"); assert_eq!(args["amount"], "42"); + assert_eq!(args["msg"], MARKET_SUPPLY_MSG); + } + + #[test] + fn request_market_withdrawal_builds_market_queue_call() { + let contract = test_contract(); + context(&account("curator.near")); + + let _ = contract.request_market_withdrawal(U128(42)); + + let (receiver, method, args, attached_deposit, prepaid_gas) = first_function_call(); + assert_eq!(receiver, account("templar-market.near")); + assert_eq!(method, "create_supply_withdrawal_request"); + assert_eq!(attached_deposit, NO_DEPOSIT); + assert_eq!(prepaid_gas, GAS_MARKET_WITHDRAWAL_REQUEST); + assert_eq!(args["amount"], "42"); + } + + #[test] + fn cancel_market_withdrawal_builds_market_queue_call() { + let contract = test_contract(); + context(&account("curator.near")); + + let _ = contract.cancel_market_withdrawal(); + + let (receiver, method, args, attached_deposit, prepaid_gas) = first_function_call(); + assert_eq!(receiver, account("templar-market.near")); + assert_eq!(method, "cancel_supply_withdrawal_request"); + assert_eq!(attached_deposit, NO_DEPOSIT); + assert_eq!(prepaid_gas, GAS_MARKET_WITHDRAWAL_REQUEST); + assert_eq!(args, serde_json::json!({})); + } + + #[test] + fn execute_market_withdrawal_builds_market_queue_call() { + let contract = test_contract(); + context(&account("curator.near")); + + let _ = contract.execute_market_withdrawal(Some(3)); + + let (receiver, method, args, attached_deposit, prepaid_gas) = first_function_call(); + assert_eq!(receiver, account("templar-market.near")); + assert_eq!(method, "execute_next_supply_withdrawal_request"); + assert_eq!(attached_deposit, NO_DEPOSIT); + assert_eq!(prepaid_gas, GAS_MARKET_WITHDRAWAL_EXECUTE); + assert_eq!(args["batch_limit"], 3); + } + + #[test] + fn adapter_supply_from_vault_records_position_and_bridges_to_stellar() { + let mut contract = test_contract(); + context(&account(INTENTS_CONTRACT)); + + let result = contract.mt_on_transfer( + account(INTENTS_CONTRACT), + vec![account("vault.near")], + vec!["nep245:v2_1.omni.hot.tg:1100_stellar_usdc".to_string()], + vec![U128(42)], + MARKET_SUPPLY_MSG.to_string(), + ); + + assert!(matches!(result, PromiseOrValue::Value(ref refunds) if refunds == &vec![U128(0)])); + let position = contract + .get_supply_position(account("vault.near")) + .expect("vault position should be recorded"); + assert_eq!(position["borrow_asset_deposit"]["active"], "42"); + assert_eq!( + position["borrow_asset_deposit"]["incoming"], + serde_json::json!([]) + ); + assert_eq!(position["borrow_asset_deposit"]["outgoing"], "0"); + + let (receiver, method, args, attached_deposit, _) = first_function_call(); + assert_eq!(receiver, account(INTENTS_CONTRACT)); + assert_eq!(method, "mt_withdraw"); + assert_eq!(attached_deposit, ONE_YOCTO); + assert_eq!(args["token"], "v2_1.omni.hot.tg"); + assert_eq!(args["receiver_id"], BRIDGE_REFUEL_ACCOUNT); + assert_eq!(args["token_ids"][0], "1100_stellar_usdc"); + assert_eq!(args["amounts"][0], "42"); + } + + #[test] + fn adapter_rejects_recomputed_or_wrong_intents_token() { + let mut contract = test_contract(); + context(&account(INTENTS_CONTRACT)); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + contract.mt_on_transfer( + account(INTENTS_CONTRACT), + vec![account("vault.near")], + vec!["1100_stellar_usdc".to_string()], + vec![U128(42)], + MARKET_SUPPLY_MSG.to_string(), + ) + })); + + assert!(result.is_err()); + } + + #[test] + fn adapter_withdrawal_executes_intents_transfer_back_to_vault() { + let mut contract = test_contract(); + context(&account(INTENTS_CONTRACT)); + let _ = contract.mt_on_transfer( + account(INTENTS_CONTRACT), + vec![account("vault.near")], + vec!["nep245:v2_1.omni.hot.tg:1100_stellar_usdc".to_string()], + vec![U128(42)], + MARKET_SUPPLY_MSG.to_string(), + ); + + context(&account("vault.near")); + contract.create_supply_withdrawal_request(U128(10)); + + context(&account("keeper.near")); + let result = contract.execute_next_supply_withdrawal_request(Some(1)); + let PromiseOrValue::Promise(promise) = result else { + panic!("expected withdrawal execution promise"); + }; + promise.detach(); + + let position = contract + .get_supply_position(account("vault.near")) + .expect("remaining vault position should be recorded"); + assert_eq!(position["borrow_asset_deposit"]["active"], "32"); + assert_eq!( + position["borrow_asset_deposit"]["incoming"], + serde_json::json!([]) + ); + assert_eq!(position["borrow_asset_deposit"]["outgoing"], "0"); + + let (receiver, method, args, attached_deposit, prepaid_gas) = first_function_call(); + assert_eq!(receiver, account(INTENTS_CONTRACT)); + assert_eq!(method, "mt_transfer"); + assert_eq!(attached_deposit, ONE_YOCTO); + assert_eq!(prepaid_gas, GAS_INTENTS_MT_TRANSFER); + assert_eq!(args["receiver_id"], "vault.near"); + assert_eq!( + args["token_id"], + "nep245:v2_1.omni.hot.tg:1100_stellar_usdc" + ); + assert_eq!(args["amount"], "10"); } #[test] @@ -388,12 +1109,13 @@ mod tests { "nep245:v2_1.omni.hot.tg:1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz" .to_string(), account("v2_1.omni.hot.tg"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), account("curator.near"), account("owner.near"), ); context(&account("curator.near")); - contract.withdraw_to_stellar(U128(1)); + let _ = contract.withdraw_to_stellar(U128(1)); let (_, _, args, _, _) = first_function_call(); assert_eq!( @@ -407,7 +1129,7 @@ mod tests { let contract = test_contract(); context(&account("curator.near")); - contract.withdraw_to_stellar(U128(999)); + let _ = contract.withdraw_to_stellar(U128(999)); let (receiver, method, args, attached_deposit, prepaid_gas) = first_function_call(); assert_eq!(receiver, account(INTENTS_CONTRACT)); @@ -441,7 +1163,16 @@ mod tests { let contract = test_contract(); context(&account("not-curator.near")); - contract.withdraw_to_stellar(U128(1)); + let _ = contract.withdraw_to_stellar(U128(1)); + } + + #[test] + #[should_panic(expected = "Only curator can call this method")] + fn non_curator_cannot_request_market_withdrawal() { + let contract = test_contract(); + context(&account("not-curator.near")); + + let _ = contract.request_market_withdrawal(U128(1)); } #[test] @@ -452,7 +1183,7 @@ mod tests { contract.set_curator(account("new-curator.near")); context(&account("new-curator.near")); - contract.withdraw_to_stellar(U128(5)); + let _ = contract.withdraw_to_stellar(U128(5)); let (_, method, _, _, _) = first_function_call(); assert_eq!(method, "mt_withdraw"); @@ -462,7 +1193,7 @@ mod tests { fn token_id_is_fixed_from_configuration() { let contract = test_contract(); context(&account("curator.near")); - contract.forward_to_market(U128(7)); + let _ = contract.forward_to_market(U128(7)); let (_, _, args, _, _) = first_function_call(); assert_eq!(args["token_id"], "1100_stellar_usdc"); } @@ -472,7 +1203,15 @@ mod tests { fn rejects_zero_amount() { let contract = test_contract(); context(&account("curator.near")); - contract.withdraw_to_stellar(U128(0)); + let _ = contract.withdraw_to_stellar(U128(0)); + } + + #[test] + #[should_panic(expected = "amount must be > 0")] + fn rejects_zero_market_withdrawal_request() { + let contract = test_contract(); + context(&account("curator.near")); + let _ = contract.request_market_withdrawal(U128(0)); } #[test] @@ -498,6 +1237,7 @@ mod tests { assert_eq!(config.near_market, account("templar-market.near")); assert_eq!(config.omni_token_id, "1100_stellar_usdc"); assert_eq!(config.omni_contract, account("v2_1.omni.hot.tg")); + assert_eq!(config.hot_deposit_receiver_hex, HOT_DEPOSIT_RECEIVER_HEX); } #[test] @@ -519,6 +1259,7 @@ mod tests { account("templar-market.near"), "".to_string(), account("v2_1.omni.hot.tg"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), account("curator.near"), account("owner.near"), ); @@ -532,6 +1273,7 @@ mod tests { account("templar-market.near"), "1100_stellar_usdc".to_string(), account("v2_1.omni.hot.tg"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), account("curator.near"), account("owner.near"), ); @@ -545,6 +1287,7 @@ mod tests { account("templar-market.near"), "1101_stellar_usdc".to_string(), account("v2_1.omni.hot.tg"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), account("curator.near"), account("owner.near"), ); @@ -558,6 +1301,7 @@ mod tests { account("templar-market.near"), "nep245:other.omni.hot.tg:1100_stellar_usdc".to_string(), account("v2_1.omni.hot.tg"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), account("curator.near"), account("owner.near"), ); @@ -570,6 +1314,7 @@ mod tests { account("templar-market.near"), "nep245:v2_1.omni.hot.tg:1100_stellar_usdc".to_string(), account("v2_1.omni.hot.tg"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), account("curator.near"), account("owner.near"), ); diff --git a/contract/vault/soroban/hot-bridge-adapter/Cargo.toml b/contract/vault/soroban/hot-bridge-adapter/Cargo.toml index c7f7367ba..9961f443e 100644 --- a/contract/vault/soroban/hot-bridge-adapter/Cargo.toml +++ b/contract/vault/soroban/hot-bridge-adapter/Cargo.toml @@ -19,4 +19,8 @@ soroban-sdk = { version = "25.3.0", features = ["experimental_spec_shaking_v2", stellar-contract-utils.workspace = true [dev-dependencies] +proptest = "1.4" soroban-sdk = { version = "25.3.0", features = ["testutils", "experimental_spec_shaking_v2"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } diff --git a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs index f1ea1059b..1bae5d90c 100644 --- a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -120,12 +120,7 @@ impl HotBridgeAdapterContract { env.invoke_contract::(&hot_locker, &Symbol::new(&env, "deposit"), args); let balance_after = token.balance(&adapter); - let spent = balance_before - .checked_sub(balance_after) - .ok_or(AdapterError::DepositPostconditionFailed)?; - if spent != amount { - return Err(AdapterError::DepositPostconditionFailed); - } + validate_supply_balance_change(balance_before, balance_after, amount)?; record_hot_locker_timestamp(&env, client_timestamp); increase_principal(&env, &asset, amount)?; @@ -166,9 +161,7 @@ impl HotBridgeAdapterContract { let adapter = env.current_contract_address(); let vault = get_vault(&env)?; let token = soroban_sdk::token::Client::new(&env, &asset); - if token.balance(&adapter) < amount { - return Err(AdapterError::InsufficientReturnedBalance); - } + validate_withdrawal_resources(principal, token.balance(&adapter), amount)?; token.transfer(&adapter, &vault, &amount); decrease_principal(&env, &asset, amount)?; @@ -312,6 +305,13 @@ fn next_hot_locker_timestamp(env: &Env) -> Result { .storage() .instance() .get::<_, u128>(&DataKey::LastHotClientTimestamp); + choose_hot_client_timestamp(base_timestamp, last_timestamp) +} + +fn choose_hot_client_timestamp( + base_timestamp: u128, + last_timestamp: Option, +) -> Result { let client_timestamp = if let Some(last_timestamp) = last_timestamp { if base_timestamp <= last_timestamp { last_timestamp @@ -333,6 +333,40 @@ fn record_hot_locker_timestamp(env: &Env, client_timestamp: u128) { .set(&DataKey::LastHotClientTimestamp, &client_timestamp); } +fn validate_supply_balance_change( + balance_before: i128, + balance_after: i128, + amount: i128, +) -> Result<(), AdapterError> { + if balance_before < amount { + return Err(AdapterError::InsufficientAdapterBalance); + } + + let spent = balance_before + .checked_sub(balance_after) + .ok_or(AdapterError::DepositPostconditionFailed)?; + if spent != amount { + return Err(AdapterError::DepositPostconditionFailed); + } + + Ok(()) +} + +fn validate_withdrawal_resources( + principal: i128, + adapter_balance: i128, + amount: i128, +) -> Result<(), AdapterError> { + if principal < amount { + return Err(AdapterError::InsufficientPrincipal); + } + if adapter_balance < amount { + return Err(AdapterError::InsufficientReturnedBalance); + } + + Ok(()) +} + fn principal_of(env: &Env, asset: &Address) -> i128 { env.storage() .instance() @@ -340,10 +374,23 @@ fn principal_of(env: &Env, asset: &Address) -> i128 { .unwrap_or(0) } -fn increase_principal(env: &Env, asset: &Address, amount: i128) -> Result<(), AdapterError> { - let updated = principal_of(env, asset) +fn principal_after_increase(current: i128, amount: i128) -> Result { + current .checked_add(amount) - .ok_or(AdapterError::ArithmeticOverflow)?; + .ok_or(AdapterError::ArithmeticOverflow) +} + +fn principal_after_decrease(current: i128, amount: i128) -> Result { + if current < amount { + return Err(AdapterError::InsufficientPrincipal); + } + current + .checked_sub(amount) + .ok_or(AdapterError::ArithmeticUnderflow) +} + +fn increase_principal(env: &Env, asset: &Address, amount: i128) -> Result<(), AdapterError> { + let updated = principal_after_increase(principal_of(env, asset), amount)?; env.storage() .instance() .set(&DataKey::Principal(asset.clone()), &updated); @@ -351,13 +398,7 @@ fn increase_principal(env: &Env, asset: &Address, amount: i128) -> Result<(), Ad } fn decrease_principal(env: &Env, asset: &Address, amount: i128) -> Result<(), AdapterError> { - let principal = principal_of(env, asset); - if principal < amount { - return Err(AdapterError::InsufficientPrincipal); - } - let updated = principal - .checked_sub(amount) - .ok_or(AdapterError::ArithmeticUnderflow)?; + let updated = principal_after_decrease(principal_of(env, asset), amount)?; let key = DataKey::Principal(asset.clone()); if updated == 0 { env.storage().instance().remove(&key); @@ -467,12 +508,167 @@ fn extend_instance_ttl(env: &Env) { .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); } +#[allow(unexpected_cfgs)] +#[cfg(kani)] +mod kani_proofs { + use super::*; + + #[kani::proof] + fn choose_hot_client_timestamp_matches_nonce_policy() { + let base_timestamp: u128 = kani::any(); + let has_last: bool = kani::any(); + let raw_last_timestamp: u128 = kani::any(); + let last_timestamp = has_last.then_some(raw_last_timestamp); + + let selected = choose_hot_client_timestamp(base_timestamp, last_timestamp); + + match last_timestamp { + None => { + assert_eq!(selected, Ok(base_timestamp)); + } + Some(last) if base_timestamp > last => { + assert_eq!(selected, Ok(base_timestamp)); + } + Some(0) => { + assert_eq!(selected, Err(AdapterError::HotClientTimestampExhausted)); + } + Some(last) => { + let selected = selected.expect("last > 0 can always step down"); + assert_eq!(selected, last - 1); + assert!(selected < last); + assert_ne!(selected, last); + } + } + } + + #[kani::proof] + fn hot_locker_timestamp_handles_boundary_ledger_timestamps() { + let zero = hot_locker_timestamp(0).expect("zero timestamp scales"); + assert_eq!(zero, 0); + + let one = hot_locker_timestamp(1).expect("one timestamp scales"); + assert_eq!(one, HOT_TIMESTAMP_SCALE); + + let max = hot_locker_timestamp(u64::MAX).expect("max u64 timestamp scales"); + assert_eq!(max, u128::from(u64::MAX) * HOT_TIMESTAMP_SCALE); + + let next_after_max = choose_hot_client_timestamp(max, Some(max)) + .expect("max scaled timestamp can step down"); + assert_eq!(next_after_max, max - 1); + } + + #[kani::proof] + fn positive_i128_amounts_convert_losslessly_to_u128() { + let amount: i128 = kani::any(); + + if amount > 0 { + assert_eq!(require_positive_amount(amount), Ok(())); + assert_eq!( + amount_as_u128(amount), + Ok(u128::try_from(amount).expect("positive i128 always fits u128")) + ); + } else { + assert_eq!( + require_positive_amount(amount), + Err(AdapterError::InvalidInput) + ); + } + } + + #[kani::proof] + fn principal_increase_is_checked_and_monotonic_for_valid_state() { + let current: i128 = kani::any(); + let amount: i128 = kani::any(); + kani::assume(current >= 0); + kani::assume(amount > 0); + + let updated = principal_after_increase(current, amount); + + match current.checked_add(amount) { + Some(expected) => { + assert_eq!(updated, Ok(expected)); + let updated = updated.expect("checked add succeeded"); + assert!(updated >= current); + assert!(updated >= amount); + } + None => { + assert_eq!(updated, Err(AdapterError::ArithmeticOverflow)); + } + } + } + + #[kani::proof] + fn principal_decrease_is_checked_and_never_negative_for_valid_state() { + let current: i128 = kani::any(); + let amount: i128 = kani::any(); + kani::assume(current >= 0); + kani::assume(amount > 0); + + let updated = principal_after_decrease(current, amount); + + if amount > current { + assert_eq!(updated, Err(AdapterError::InsufficientPrincipal)); + } else { + let updated = updated.expect("sufficient principal should decrease"); + assert_eq!(updated, current - amount); + assert!(updated >= 0); + assert!(updated < current); + } + } + + #[kani::proof] + fn supply_balance_postcondition_accepts_only_exact_spend() { + let balance_before: i128 = kani::any(); + let balance_after: i128 = kani::any(); + let amount: i128 = kani::any(); + kani::assume(balance_before >= 0); + kani::assume(balance_after >= 0); + kani::assume(amount > 0); + + let result = validate_supply_balance_change(balance_before, balance_after, amount); + + if balance_before < amount { + assert_eq!(result, Err(AdapterError::InsufficientAdapterBalance)); + } else if balance_after > balance_before { + assert_eq!(result, Err(AdapterError::DepositPostconditionFailed)); + } else { + let spent = balance_before - balance_after; + if spent == amount { + assert_eq!(result, Ok(())); + } else { + assert_eq!(result, Err(AdapterError::DepositPostconditionFailed)); + } + } + } + + #[kani::proof] + fn withdrawal_resources_require_principal_and_returned_balance() { + let principal: i128 = kani::any(); + let adapter_balance: i128 = kani::any(); + let amount: i128 = kani::any(); + kani::assume(principal >= 0); + kani::assume(adapter_balance >= 0); + kani::assume(amount > 0); + + let result = validate_withdrawal_resources(principal, adapter_balance, amount); + + if principal < amount { + assert_eq!(result, Err(AdapterError::InsufficientPrincipal)); + } else if adapter_balance < amount { + assert_eq!(result, Err(AdapterError::InsufficientReturnedBalance)); + } else { + assert_eq!(result, Ok(())); + } + } +} + #[cfg(test)] mod tests { use super::*; + use proptest::prelude::*; use soroban_sdk::{ contract, contractimpl, - testutils::{Address as _, Events as _, Ledger as _}, + testutils::{Address as _, EnvTestConfig, Events as _, Ledger as _}, token::StellarAssetClient, xdr::{ContractEventBody, ScVal}, TryFromVal, @@ -587,6 +783,12 @@ mod tests { (adapter, admin, vault, hot_locker, asset, receiver) } + fn fuzz_env() -> Env { + Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }) + } + fn assert_hot_deposit_event( env: &Env, adapter: &Address, @@ -613,6 +815,138 @@ mod tests { })); } + fn choose_expected_timestamp(base_timestamp: u128, last_timestamp: &mut Option) -> u128 { + let selected = choose_hot_client_timestamp(base_timestamp, *last_timestamp) + .expect("test timestamps should not exhaust"); + *last_timestamp = Some(selected); + selected + } + + #[derive(Clone, Debug)] + enum AdapterOp { + Supply { amount: i128, ledger_delta: u64 }, + Withdraw { amount: i128 }, + } + + fn adapter_op_strategy() -> impl Strategy { + prop_oneof![ + (1i128..=50_000i128, 0u64..=2u64).prop_map(|(amount, ledger_delta)| { + AdapterOp::Supply { + amount, + ledger_delta, + } + }), + (1i128..=50_000i128).prop_map(|amount| AdapterOp::Withdraw { amount }), + ] + } + + proptest! { + #[test] + fn prop_hot_timestamp_selection_never_reuses_last( + base_timestamp in any::(), + last_timestamp in prop::option::of(any::()), + ) { + let selected = choose_hot_client_timestamp(base_timestamp, last_timestamp); + + match last_timestamp { + None => { + prop_assert_eq!(selected, Ok(base_timestamp)); + } + Some(last) if base_timestamp > last => { + prop_assert_eq!(selected, Ok(base_timestamp)); + } + Some(0) => { + prop_assert_eq!(selected, Err(AdapterError::HotClientTimestampExhausted)); + } + Some(last) => { + let selected = selected.expect("last > 0 should step down"); + prop_assert_eq!(selected, last - 1); + prop_assert!(selected < last); + } + } + } + + #[test] + fn prop_supply_and_withdraw_sequences_preserve_principal_and_balances( + ops in prop::collection::vec(adapter_op_strategy(), 1..25), + ) { + let env = fuzz_env(); + let (adapter, _admin, vault, hot_locker, asset, _receiver) = setup(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let asset_admin = StellarAssetClient::new(&env, &asset); + let token = soroban_sdk::token::Client::new(&env, &asset); + let locker_client = MockHotLockerClient::new(&env, &hot_locker); + let mut model_principal = 0i128; + let mut model_vault_balance = 0i128; + let mut model_locker_balance = 0i128; + let mut ledger_timestamp = 1_777_000_000u64; + let mut last_hot_timestamp = None; + + for op in ops { + match op { + AdapterOp::Supply { amount, ledger_delta } => { + ledger_timestamp = ledger_timestamp.saturating_add(ledger_delta); + env.ledger().set_timestamp(ledger_timestamp); + let expected_nonce = choose_expected_timestamp( + hot_locker_timestamp(ledger_timestamp).expect("bounded ledger timestamp"), + &mut last_hot_timestamp, + ); + + asset_admin.mint(&adapter, &amount); + client.supply(&vault, &asset, &amount); + model_principal += amount; + model_locker_balance += amount; + + prop_assert_eq!(locker_client.timestamp(), expected_nonce); + prop_assert_eq!(locker_client.amount(), amount); + } + AdapterOp::Withdraw { amount } => { + let result = client.try_progress_withdrawal(&vault, &asset, &amount); + if amount > model_principal { + prop_assert_eq!(result, Err(Ok(AdapterError::InsufficientPrincipal))); + } else { + prop_assert_eq!(result, Err(Ok(AdapterError::InsufficientReturnedBalance))); + + asset_admin.mint(&adapter, &amount); + prop_assert_eq!(client.progress_withdrawal(&vault, &asset, &amount), amount); + model_principal -= amount; + model_vault_balance += amount; + } + } + } + + prop_assert_eq!(client.total_assets(&asset), model_principal); + prop_assert_eq!(token.balance(&vault), model_vault_balance); + prop_assert_eq!(token.balance(&hot_locker), model_locker_balance); + prop_assert_eq!(token.balance(&adapter), 0); + } + } + + #[test] + fn prop_failed_no_transfer_supply_preserves_principal_and_balance(amount in 1i128..=1_000_000i128) { + let env = fuzz_env(); + env.mock_all_auths(); + let admin = register_dummy_contract(&env); + let vault = register_dummy_contract(&env); + let hot_locker = env.register(MockNoTransferHotLocker, ()); + let asset_sac = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let asset = asset_sac.address(); + let receiver = BytesN::from_array(&env, &[7; 32]); + let adapter = env.register( + HotBridgeAdapterContract, + (&admin, &vault, &hot_locker, &receiver), + ); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let token = soroban_sdk::token::Client::new(&env, &asset); + StellarAssetClient::new(&env, &asset).mint(&adapter, &amount); + + prop_assert_eq!(client.try_supply(&vault, &asset, &amount), Err(Ok(AdapterError::DepositPostconditionFailed))); + prop_assert_eq!(client.total_assets(&asset), 0); + prop_assert_eq!(token.balance(&adapter), amount); + prop_assert_eq!(token.balance(&hot_locker), 0); + } + } + #[test] fn supply_calls_hot_locker_with_configured_receiver_id() { let env = Env::default(); From 07c404aa43f247e79cd9812a9d6740af63f81562 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 4 Jun 2026 13:07:22 +0200 Subject: [PATCH 13/18] fix: harden HOT bridge adapter audit findings --- contract/vault-counterparty/README.md | 15 +- contract/vault-counterparty/src/lib.rs | 532 ++++++++++++++++-- contract/vault/soroban/README.md | 15 +- .../soroban/hot-bridge-adapter/src/lib.rs | 398 +++++++++++-- .../tests/mainnet_locker_wasm.rs | 10 +- contract/vault/soroban/justfile | 7 + 6 files changed, 896 insertions(+), 81 deletions(-) diff --git a/contract/vault-counterparty/README.md b/contract/vault-counterparty/README.md index dab5bc5ca..a6e6daaa0 100644 --- a/contract/vault-counterparty/README.md +++ b/contract/vault-counterparty/README.md @@ -7,6 +7,8 @@ This crate is a spike for the HOT Bridge counterparty described in the Stellar < - Curator-only methods build the expected outbound calls to the configured `omni_contract`: - `mt_transfer_call(receiver_id, token_id, amount, msg)` with `msg = "\"Supply\""` for market deposits - `intents.near mt_withdraw(token, receiver_id, token_ids, amounts, msg)` for HOT withdrawals +- External Intents operations are recorded with an operation id, `Pending` status, and a private + callback that marks them `Succeeded` or `Failed`; retries are allowed only from `Failed`. - The market-like adapter entrypoint accepts vault supply through `intents.near mt_transfer_call` with token id `nep245::` and `msg = "\"Supply\""`. - HOT asset transfer calls attach exactly `1 yoctoNEAR`; market queue calls attach no deposit. @@ -16,8 +18,10 @@ This crate is a spike for the HOT Bridge counterparty described in the Stellar < market supply withdrawal queue before returned assets are withdrawn to Stellar. - `withdraw_to_stellar` always uses the contract's configured `stellar_receiver` and only spends the counterparty's current Intents balance. -- `token_id` is immutable (`omni_token_id`) from init; runtime methods do not accept token overrides. -- omni contract is configurable at init (`omni_contract`) to allow bridge transport swaps. +- `token_id` is configured (`omni_token_id`) and runtime methods do not accept token overrides. +- Critical routing config is changed through owner-only two-step updates. +- The Intents contract and bridge refuel account are versioned state, initialized to the production + defaults and changed through owner-only two-step updates. - `hot_deposit_receiver_hex` is explicit init config. It must come from the verified HOT receiver bytes instead of local recomputation. - No runtime callback `msg` input is exposed in this spike (no arbitrary action payloads). @@ -26,6 +30,9 @@ This crate is a spike for the HOT Bridge counterparty described in the Stellar < - Reject empty `token_id` at initialization - Cap `token_id` and `stellar_receiver` length - Two-step owner transfer (`propose_owner` -> `accept_owner`) + - One-yocto deposit required for owner role/config operations + - Owner pause blocks forward and HOT withdrawal operations + - Minimum gas budget required before creating HOT withdrawal promises The tests inspect emitted mock receipts directly, so method names, JSON arg shapes, gas, and deposit are all checked. @@ -56,7 +63,9 @@ When the vault uses this contract as the Stellar market adapter, the flow is: - A real Soroban adapter address (for example from `upstream/feat/soroban-curator-kernel`) - A mock placeholder receiver during local testing -`near_market` and `omni_token_id` are set at init and immutable in this spike, so curator cannot route to arbitrary receivers or switch bridged assets at call time. `omni_contract` is also set at init so the same contract can target a non-HOT multitoken contract in the future. +`near_market`, `omni_token_id`, `omni_contract`, and HOT receiver config are set at init and can be +rotated only through owner-controlled two-step config updates, so curator cannot route to arbitrary +receivers or switch bridged assets at call time. ## Run diff --git a/contract/vault-counterparty/src/lib.rs b/contract/vault-counterparty/src/lib.rs index 13063afc6..00ea95a30 100644 --- a/contract/vault-counterparty/src/lib.rs +++ b/contract/vault-counterparty/src/lib.rs @@ -5,7 +5,7 @@ use near_sdk::{ json_types::U128, near, require, serde_json::{self, json}, - AccountId, Gas, NearToken, PanicOnDefault, Promise, PromiseOrValue, + AccountId, Gas, NearToken, PanicOnDefault, Promise, PromiseOrValue, PromiseResult, }; use std::collections::BTreeMap; use std::str::FromStr; @@ -19,6 +19,7 @@ const GAS_MARKET_WITHDRAWAL_EXECUTE: Gas = Gas::from_tgas(100); const GAS_INTENTS_MT_TRANSFER: Gas = Gas::from_tgas(50); const GAS_WITHDRAW_TARGET: Gas = Gas::from_tgas(250); const GAS_WITHDRAW_BUFFER: Gas = Gas::from_tgas(20); +const GAS_OPERATION_CALLBACK: Gas = Gas::from_tgas(10); const INTENTS_CONTRACT: &str = "intents.near"; const BRIDGE_REFUEL_ACCOUNT: &str = "bridge-refuel.hot.tg"; const MARKET_SUPPLY_MSG: &str = "\"Supply\""; @@ -26,10 +27,12 @@ const MAX_STELLAR_RECEIVER_LEN: usize = 256; const MAX_TOKEN_ID_LEN: usize = 256; const HOT_DEPOSIT_RECEIVER_HEX_LEN: usize = 64; const HOT_STELLAR_CHAIN_PREFIX: &str = "1100_"; +const STORAGE_VERSION: u32 = 1; #[derive(Debug, Clone)] #[near(serializers = [json])] pub struct Config { + pub schema_version: u32, pub stellar_receiver: String, pub near_market: AccountId, pub omni_token_id: String, @@ -38,13 +41,17 @@ pub struct Config { pub pending_owner: Option, pub omni_contract: AccountId, pub hot_deposit_receiver_hex: String, + pub intents_contract: AccountId, + pub bridge_refuel_account: AccountId, + pub pending_intents_integration: Option, + pub paused: bool, + pub pending_config_update: Option, } #[derive(Debug, Clone)] #[near(serializers = [borsh])] struct StellarReceiver { raw: String, - encoded: String, } impl StellarReceiver { @@ -63,19 +70,18 @@ impl StellarReceiver { let sc_address = ScAddress::from_str(&receiver) .unwrap_or_else(|_| env::panic_str("invalid stellar receiver")); - let encoded = Contract::encode_stellar_sc_address(&sc_address); - Self { - raw: receiver, - encoded, - } + let canonical = sc_address.to_string(); + Self { raw: canonical } } fn as_str(&self) -> &str { &self.raw } - fn encoded(&self) -> &str { - &self.encoded + fn encoded(&self) -> String { + let sc_address = ScAddress::from_str(&self.raw) + .unwrap_or_else(|_| env::panic_str("stored stellar receiver is invalid")); + Contract::encode_stellar_sc_address(&sc_address) } } @@ -139,9 +145,52 @@ impl HotDepositReceiver { } } +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh, json])] +pub struct ConfigUpdate { + pub stellar_receiver: String, + pub near_market: AccountId, + pub omni_token_id: String, + pub omni_contract: AccountId, + pub hot_deposit_receiver_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh, json])] +pub struct IntentsIntegration { + pub contract: AccountId, + pub bridge_refuel_account: AccountId, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[near(serializers = [borsh, json])] +pub enum OperationKind { + ForwardToMarket, + WithdrawToStellar, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh, json])] +pub enum OperationStatus { + Pending, + Succeeded, + Failed, + Retrying { attempts: u8 }, +} + +#[derive(Debug, Clone)] +#[near(serializers = [borsh, json])] +pub struct Operation { + pub id: u64, + pub kind: OperationKind, + pub amount: U128, + pub status: OperationStatus, +} + #[derive(PanicOnDefault)] #[near(contract_state)] pub struct Contract { + schema_version: u32, stellar_receiver: StellarReceiver, near_market: AccountId, omni_token_id: OmniTokenId, @@ -150,6 +199,12 @@ pub struct Contract { pending_owner: Option, omni_contract: AccountId, hot_deposit_receiver: HotDepositReceiver, + intents_integration: IntentsIntegration, + paused: bool, + pending_config_update: Option, + pending_intents_integration: Option, + operations: BTreeMap, + next_operation_id: u64, supply_positions: BTreeMap, withdrawal_requests: BTreeMap, } @@ -171,6 +226,7 @@ impl Contract { let hot_deposit_receiver = HotDepositReceiver::new(hot_deposit_receiver_hex); Self { + schema_version: STORAGE_VERSION, stellar_receiver, near_market, omni_token_id, @@ -179,14 +235,25 @@ impl Contract { pending_owner: None, omni_contract, hot_deposit_receiver, + intents_integration: IntentsIntegration { + contract: account_id(INTENTS_CONTRACT), + bridge_refuel_account: account_id(BRIDGE_REFUEL_ACCOUNT), + }, + paused: false, + pending_config_update: None, + pending_intents_integration: None, + operations: BTreeMap::new(), + next_operation_id: 1, supply_positions: BTreeMap::new(), withdrawal_requests: BTreeMap::new(), } } - pub fn forward_to_market(&self, amount: U128) -> Promise { + pub fn forward_to_market(&mut self, amount: U128) -> Promise { + self.assert_not_paused(); self.assert_curator(); Self::assert_amount(amount); + let operation_id = self.start_operation(OperationKind::ForwardToMarket, amount.0); self.call_omni( "mt_transfer_call", @@ -198,9 +265,15 @@ impl Contract { }), GAS_MT_TRANSFER_CALL, ) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_OPERATION_CALLBACK) + .on_operation_complete(operation_id), + ) } pub fn request_market_withdrawal(&self, amount: U128) -> Promise { + self.assert_not_paused(); self.assert_curator(); Self::assert_amount(amount); @@ -214,6 +287,7 @@ impl Contract { } pub fn cancel_market_withdrawal(&self) -> Promise { + self.assert_not_paused(); self.assert_curator(); self.call_market( @@ -224,6 +298,7 @@ impl Contract { } pub fn execute_market_withdrawal(&self, batch_limit: Option) -> Promise { + self.assert_not_paused(); self.assert_curator(); self.call_market( @@ -294,11 +369,18 @@ impl Contract { PromiseOrValue::Promise(self.intents_transfer_promise(account_id, U128(amount))) } - pub fn withdraw_to_stellar(&self, amount: U128) -> Promise { + pub fn withdraw_to_stellar(&mut self, amount: U128) -> Promise { + self.assert_not_paused(); self.assert_curator(); Self::assert_amount(amount); + self.assert_withdraw_gas_budget(); - self.hot_withdraw_promise(amount) + let operation_id = self.start_operation(OperationKind::WithdrawToStellar, amount.0); + self.hot_withdraw_promise(amount, operation_id).then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_OPERATION_CALLBACK) + .on_operation_complete(operation_id), + ) } pub fn mt_on_transfer( @@ -312,7 +394,7 @@ impl Contract { let _authorized_sender = sender_id; require!( - env::predecessor_account_id() == account_id(INTENTS_CONTRACT), + env::predecessor_account_id() == self.intents_integration.contract, "Only Intents can transfer-call this adapter" ); require!( @@ -330,12 +412,21 @@ impl Contract { let supplier = previous_owner_ids[0].clone(); self.increase_supply_position(supplier, amount.0); - self.hot_withdraw_promise(amount).detach(); + self.assert_not_paused(); + self.assert_withdraw_gas_budget(); + let operation_id = self.start_operation(OperationKind::WithdrawToStellar, amount.0); + self.hot_withdraw_promise(amount, operation_id) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_OPERATION_CALLBACK) + .on_operation_complete(operation_id), + ) + .detach(); PromiseOrValue::Value(vec![U128(0)]) } - fn hot_withdraw_promise(&self, amount: U128) -> Promise { + fn hot_withdraw_promise(&self, amount: U128, operation_id: u64) -> Promise { let remaining_gas = Gas::from_gas( env::prepaid_gas() .as_gas() @@ -344,15 +435,15 @@ impl Contract { ); let forwarded_gas = Gas::from_gas(remaining_gas.as_gas().min(GAS_WITHDRAW_TARGET.as_gas())); - Promise::new(account_id(INTENTS_CONTRACT)).function_call( + Promise::new(self.intents_integration.contract.clone()).function_call( "mt_withdraw".to_string(), serde_json::to_vec(&json!({ "token": self.intents_token_contract(), - "receiver_id": BRIDGE_REFUEL_ACCOUNT, + "receiver_id": self.intents_integration.bridge_refuel_account, "token_ids": [self.intents_multi_token_id()], "amounts": [amount.0.to_string()], "memo": serde_json::Value::Null, - "msg": self.withdraw_msg_json(), + "msg": self.withdraw_msg_json(operation_id), })) .unwrap_or_else(|_| env::panic_str("failed to serialize withdrawal args")), ONE_YOCTO, @@ -360,11 +451,12 @@ impl Contract { ) } - fn withdraw_msg_json(&self) -> String { + fn withdraw_msg_json(&self, operation_id: u64) -> String { json!({ "receiver_id": self.stellar_receiver.encoded(), "amount_native": "0", "block_number": 0, + "request_id": operation_id.to_string(), }) .to_string() } @@ -406,7 +498,7 @@ impl Contract { } fn intents_transfer_promise(&self, receiver_id: AccountId, amount: U128) -> Promise { - Promise::new(account_id(INTENTS_CONTRACT)).function_call( + Promise::new(self.intents_integration.contract.clone()).function_call( "mt_transfer".to_string(), serde_json::to_vec(&json!({ "receiver_id": receiver_id, @@ -431,6 +523,87 @@ impl Contract { self.supply_positions.insert(account_id, updated); } + fn start_operation(&mut self, kind: OperationKind, amount: u128) -> u64 { + let id = self.next_operation_id; + self.next_operation_id = self + .next_operation_id + .checked_add(1) + .unwrap_or_else(|| env::panic_str("operation id overflow")); + let operation = Operation { + id, + kind, + amount: U128(amount), + status: OperationStatus::Pending, + }; + self.operations.insert(id, operation); + env::log_str(&format!("operation_pending:{id}")); + id + } + + fn mark_operation_result(&mut self, operation_id: u64, succeeded: bool) { + let Some(operation) = self.operations.get_mut(&operation_id) else { + env::panic_str("unknown operation id"); + }; + operation.status = if succeeded { + OperationStatus::Succeeded + } else { + OperationStatus::Failed + }; + env::log_str(if succeeded { + "operation_succeeded" + } else { + "operation_failed" + }); + } + + fn retry_tracked_operation(&mut self, operation_id: u64) -> Promise { + self.assert_not_paused(); + self.assert_curator(); + let operation = self + .operations + .get(&operation_id) + .unwrap_or_else(|| env::panic_str("unknown operation id")); + require!( + operation.status == OperationStatus::Failed, + "operation can only be retried from failed status" + ); + let kind = operation.kind; + let amount = operation.amount; + if kind == OperationKind::WithdrawToStellar { + self.assert_withdraw_gas_budget(); + } + + let operation = self + .operations + .get_mut(&operation_id) + .unwrap_or_else(|| env::panic_str("unknown operation id")); + operation.status = match operation.status { + OperationStatus::Retrying { attempts } => OperationStatus::Retrying { + attempts: attempts.saturating_add(1), + }, + _ => OperationStatus::Retrying { attempts: 1 }, + }; + + match kind { + OperationKind::ForwardToMarket => self.call_omni( + "mt_transfer_call", + json!({ + "receiver_id": self.near_market, + "token_id": self.omni_token_id_for_contract(), + "amount": amount, + "msg": MARKET_SUPPLY_MSG, + }), + GAS_MT_TRANSFER_CALL, + ), + OperationKind::WithdrawToStellar => self.hot_withdraw_promise(amount, operation_id), + } + .then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_OPERATION_CALLBACK) + .on_operation_complete(operation_id), + ) + } + fn decrease_supply_position(&mut self, account_id: &AccountId, amount: u128) { let current = self.supply_positions.get(account_id).copied().unwrap_or(0); require!(current >= amount, "Insufficient adapter principal"); @@ -488,20 +661,41 @@ fn account_id(value: &str) -> AccountId { #[near] impl Contract { + #[private] + pub fn on_operation_complete(&mut self, operation_id: u64) { + #[allow(deprecated)] + let succeeded = matches!(env::promise_result(0), PromiseResult::Successful(_)); + self.mark_operation_result(operation_id, succeeded); + } + + pub fn get_operation(&self, operation_id: u64) -> Option { + self.operations.get(&operation_id).cloned() + } + + pub fn retry_operation(&mut self, operation_id: u64) -> Promise { + self.retry_tracked_operation(operation_id) + } + + #[payable] pub fn set_curator(&mut self, curator: AccountId) { + assert_one_yocto(); self.assert_owner(); self.curator = curator; env::log_str("curator_updated"); } + #[payable] pub fn propose_owner(&mut self, pending_owner: AccountId) { + assert_one_yocto(); self.assert_owner(); require!(pending_owner != self.owner, "new owner must differ"); self.pending_owner = Some(pending_owner); env::log_str("owner_proposed"); } + #[payable] pub fn accept_owner(&mut self) { + assert_one_yocto(); let predecessor = env::predecessor_account_id(); require!( self.pending_owner @@ -514,8 +708,75 @@ impl Contract { env::log_str("owner_accepted"); } + #[payable] + pub fn pause(&mut self) { + assert_one_yocto(); + self.assert_owner(); + require!(!self.paused, "already paused"); + self.paused = true; + env::log_str("paused"); + } + + #[payable] + pub fn unpause(&mut self) { + assert_one_yocto(); + self.assert_owner(); + require!(self.paused, "not paused"); + self.paused = false; + env::log_str("unpaused"); + } + + #[payable] + pub fn propose_config_update(&mut self, update: ConfigUpdate) { + assert_one_yocto(); + self.assert_owner(); + self.validate_config_update(&update); + self.pending_config_update = Some(update); + env::log_str("config_update_proposed"); + } + + #[payable] + pub fn accept_config_update(&mut self) { + assert_one_yocto(); + self.assert_owner(); + let update = self + .pending_config_update + .take() + .unwrap_or_else(|| env::panic_str("no pending config update")); + self.validate_config_update(&update); + self.stellar_receiver = StellarReceiver::new(update.stellar_receiver); + self.near_market = update.near_market; + self.omni_token_id = OmniTokenId::new(update.omni_token_id, &update.omni_contract); + self.omni_contract = update.omni_contract; + self.hot_deposit_receiver = HotDepositReceiver::new(update.hot_deposit_receiver_hex); + env::log_str("config_update_accepted"); + } + + #[payable] + pub fn propose_intents_integration(&mut self, integration: IntentsIntegration) { + assert_one_yocto(); + self.assert_owner(); + self.validate_intents_integration(&integration); + self.pending_intents_integration = Some(integration); + env::log_str("intents_integration_proposed"); + } + + #[payable] + pub fn accept_intents_integration(&mut self) { + assert_one_yocto(); + self.assert_owner(); + let integration = self + .pending_intents_integration + .take() + .unwrap_or_else(|| env::panic_str("no pending intents integration")); + self.validate_intents_integration(&integration); + self.intents_integration = integration; + env::log_str("intents_integration_accepted"); + } + pub fn get_config(&self) -> Config { Config { + schema_version: self.schema_version, stellar_receiver: self.stellar_receiver.as_str().to_string(), near_market: self.near_market.clone(), omni_token_id: self.omni_token_id.as_str().to_string(), @@ -524,6 +785,11 @@ impl Contract { pending_owner: self.pending_owner.clone(), omni_contract: self.omni_contract.clone(), hot_deposit_receiver_hex: self.hot_deposit_receiver.as_str().to_string(), + intents_contract: self.intents_integration.contract.clone(), + bridge_refuel_account: self.intents_integration.bridge_refuel_account.clone(), + pending_intents_integration: self.pending_intents_integration.clone(), + paused: self.paused, + pending_config_update: self.pending_config_update.clone(), } } @@ -541,9 +807,50 @@ impl Contract { ); } + fn assert_not_paused(&self) { + require!(!self.paused, "contract is paused"); + } + + fn assert_withdraw_gas_budget(&self) { + let available = env::prepaid_gas() + .as_gas() + .saturating_sub(env::used_gas().as_gas()); + let required = GAS_WITHDRAW_TARGET + .as_gas() + .saturating_add(GAS_WITHDRAW_BUFFER.as_gas()) + .saturating_add(GAS_OPERATION_CALLBACK.as_gas()); + require!(available >= required, "insufficient prepaid gas"); + } + fn assert_amount(amount: U128) { require!(amount.0 > 0, "amount must be > 0"); } + + fn validate_config_update(&self, update: &ConfigUpdate) { + let _stellar_receiver = StellarReceiver::new(update.stellar_receiver.clone()); + let _omni_token_id = OmniTokenId::new(update.omni_token_id.clone(), &update.omni_contract); + let _hot_deposit_receiver = + HotDepositReceiver::new(update.hot_deposit_receiver_hex.clone()); + require!( + update.near_market != env::current_account_id(), + "near market cannot be this contract" + ); + } + + fn validate_intents_integration(&self, integration: &IntentsIntegration) { + require!( + integration.contract != env::current_account_id(), + "intents contract cannot be this contract" + ); + require!( + integration.bridge_refuel_account != env::current_account_id(), + "bridge refuel account cannot be this contract" + ); + } +} + +fn assert_one_yocto() { + require!(env::attached_deposit() == ONE_YOCTO, "requires one yocto"); } #[cfg(test)] @@ -552,9 +859,10 @@ mod tests { mock::MockAction, serde_json::Value, test_utils::{get_created_receipts, VMContextBuilder}, - testing_env, AccountId, + test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, }; use proptest::prelude::*; + use std::collections::HashMap; use super::*; @@ -568,14 +876,43 @@ mod tests { } fn context(predecessor: &AccountId) { + context_with_deposit(predecessor, NO_DEPOSIT); + } + + fn context_with_deposit(predecessor: &AccountId, attached_deposit: NearToken) { let mut builder = VMContextBuilder::new(); builder.current_account_id(account("counterparty.near")); builder.predecessor_account_id(predecessor.clone()); builder.signer_account_id(predecessor.clone()); builder.prepaid_gas(Gas::from_tgas(400)); + builder.attached_deposit(attached_deposit); + testing_env!(builder.build()); + } + + fn context_with_gas(predecessor: &AccountId, prepaid_gas: Gas) { + let mut builder = VMContextBuilder::new(); + builder.current_account_id(account("counterparty.near")); + builder.predecessor_account_id(predecessor.clone()); + builder.signer_account_id(predecessor.clone()); + builder.prepaid_gas(prepaid_gas); testing_env!(builder.build()); } + fn context_with_promise_results(predecessor: &AccountId, promise_results: Vec) { + let mut builder = VMContextBuilder::new(); + builder.current_account_id(account("counterparty.near")); + builder.predecessor_account_id(predecessor.clone()); + builder.signer_account_id(predecessor.clone()); + builder.prepaid_gas(Gas::from_tgas(400)); + testing_env!( + builder.build(), + test_vm_config(), + RuntimeFeesConfig::test(), + HashMap::default(), + promise_results + ); + } + fn test_contract() -> Contract { Contract::new( "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), @@ -590,7 +927,10 @@ mod tests { fn first_function_call() -> (AccountId, String, Value, NearToken, Gas) { let receipts = get_created_receipts(); - assert_eq!(receipts.len(), 1, "expected exactly one outgoing receipt"); + assert!( + !receipts.is_empty(), + "expected at least one outgoing receipt" + ); let receipt = &receipts[0]; assert_eq!(receipt.actions.len(), 1, "expected exactly one action"); @@ -943,7 +1283,7 @@ mod tests { #[test] fn forward_to_market_builds_expected_mt_transfer_call_supply() { - let contract = test_contract(); + let mut contract = test_contract(); context(&account("curator.near")); let _ = contract.forward_to_market(U128(42)); @@ -1103,7 +1443,7 @@ mod tests { #[test] fn wrapped_token_id_is_normalized_for_omni_calls() { - let contract = Contract::new( + let mut contract = Contract::new( "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), account("templar-market.near"), "nep245:v2_1.omni.hot.tg:1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz" @@ -1126,7 +1466,7 @@ mod tests { #[test] fn withdraw_to_stellar_uses_hardcoded_receiver_and_token() { - let contract = test_contract(); + let mut contract = test_contract(); context(&account("curator.near")); let _ = contract.withdraw_to_stellar(U128(999)); @@ -1155,12 +1495,67 @@ mod tests { ); assert_eq!(msg["amount_native"], "0"); assert_eq!(msg["block_number"], 0); + assert_eq!(msg["request_id"], "1"); + assert_eq!( + contract + .get_operation(1) + .expect("operation should be recorded") + .status, + OperationStatus::Pending + ); + } + + #[test] + fn operation_callback_marks_failed_and_retry_is_idempotent() { + let mut contract = test_contract(); + context(&account("curator.near")); + let _ = contract.withdraw_to_stellar(U128(5)); + + context_with_promise_results(&account("counterparty.near"), vec![PromiseResult::Failed]); + contract.on_operation_complete(1); + assert_eq!( + contract + .get_operation(1) + .expect("operation should exist") + .status, + OperationStatus::Failed + ); + + context(&account("curator.near")); + let _ = contract.retry_operation(1); + assert_eq!( + contract + .get_operation(1) + .expect("operation should exist") + .status, + OperationStatus::Retrying { attempts: 1 } + ); + } + + #[test] + #[should_panic(expected = "operation can only be retried from failed status")] + fn retry_rejects_pending_operation() { + let mut contract = test_contract(); + context(&account("curator.near")); + let _ = contract.forward_to_market(U128(5)); + + context(&account("curator.near")); + let _ = contract.retry_operation(1); + } + + #[test] + #[should_panic(expected = "insufficient prepaid gas")] + fn withdraw_rejects_insufficient_forwarded_gas() { + let mut contract = test_contract(); + context_with_gas(&account("curator.near"), Gas::from_tgas(30)); + + let _ = contract.withdraw_to_stellar(U128(1)); } #[test] #[should_panic(expected = "Only curator can call this method")] fn non_curator_cannot_withdraw_to_stellar() { - let contract = test_contract(); + let mut contract = test_contract(); context(&account("not-curator.near")); let _ = contract.withdraw_to_stellar(U128(1)); @@ -1175,11 +1570,20 @@ mod tests { let _ = contract.request_market_withdrawal(U128(1)); } + #[test] + #[should_panic(expected = "Only curator can call this method")] + fn non_curator_cannot_forward_to_market() { + let mut contract = test_contract(); + context(&account("not-curator.near")); + + let _ = contract.forward_to_market(U128(1)); + } + #[test] fn owner_can_rotate_curator() { let mut contract = test_contract(); - context(&account("owner.near")); + context_with_deposit(&account("owner.near"), ONE_YOCTO); contract.set_curator(account("new-curator.near")); context(&account("new-curator.near")); @@ -1189,9 +1593,69 @@ mod tests { assert_eq!(method, "mt_withdraw"); } + #[test] + #[should_panic(expected = "requires one yocto")] + fn owner_methods_require_one_yocto() { + let mut contract = test_contract(); + context(&account("owner.near")); + + contract.set_curator(account("new-curator.near")); + } + + #[test] + fn pause_blocks_forward_and_withdraw() { + let mut contract = test_contract(); + context_with_deposit(&account("owner.near"), ONE_YOCTO); + contract.pause(); + + context(&account("curator.near")); + assert!(catch_contract_panic(|| { + let _ = contract.forward_to_market(U128(1)); + })); + assert!(catch_contract_panic(|| { + let _ = contract.withdraw_to_stellar(U128(1)); + })); + + context_with_deposit(&account("owner.near"), ONE_YOCTO); + contract.unpause(); + assert!(!contract.get_config().paused); + } + + #[test] + fn config_rotation_is_two_step_and_validated() { + let mut contract = test_contract(); + let update = ConfigUpdate { + stellar_receiver: "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV" + .to_string(), + near_market: account("new-market.near"), + omni_token_id: "1100_new_usdc".to_string(), + omni_contract: account("new-omni.hot.tg"), + hot_deposit_receiver_hex: HOT_DEPOSIT_RECEIVER_HEX.to_string(), + }; + + context_with_deposit(&account("owner.near"), ONE_YOCTO); + contract.propose_config_update(update.clone()); + assert_eq!( + contract + .get_config() + .pending_config_update + .expect("pending update") + .near_market, + account("new-market.near") + ); + + context_with_deposit(&account("owner.near"), ONE_YOCTO); + contract.accept_config_update(); + let config = contract.get_config(); + assert_eq!(config.near_market, account("new-market.near")); + assert_eq!(config.omni_token_id, "1100_new_usdc"); + assert_eq!(config.omni_contract, account("new-omni.hot.tg")); + assert_eq!(config.pending_config_update, None); + } + #[test] fn token_id_is_fixed_from_configuration() { - let contract = test_contract(); + let mut contract = test_contract(); context(&account("curator.near")); let _ = contract.forward_to_market(U128(7)); let (_, _, args, _, _) = first_function_call(); @@ -1201,7 +1665,7 @@ mod tests { #[test] #[should_panic(expected = "amount must be > 0")] fn rejects_zero_amount() { - let contract = test_contract(); + let mut contract = test_contract(); context(&account("curator.near")); let _ = contract.withdraw_to_stellar(U128(0)); } @@ -1218,17 +1682,17 @@ mod tests { #[should_panic(expected = "Only owner can call this method")] fn non_owner_cannot_rotate_curator() { let mut contract = test_contract(); - context(&account("curator.near")); + context_with_deposit(&account("curator.near"), ONE_YOCTO); contract.set_curator(account("attacker.near")); } #[test] fn ownership_transfer_is_two_step() { let mut contract = test_contract(); - context(&account("owner.near")); + context_with_deposit(&account("owner.near"), ONE_YOCTO); contract.propose_owner(account("new-owner.near")); - context(&account("new-owner.near")); + context_with_deposit(&account("new-owner.near"), ONE_YOCTO); contract.accept_owner(); let config = contract.get_config(); @@ -1244,10 +1708,10 @@ mod tests { #[should_panic(expected = "Only pending owner can accept ownership")] fn non_pending_owner_cannot_accept_ownership() { let mut contract = test_contract(); - context(&account("owner.near")); + context_with_deposit(&account("owner.near"), ONE_YOCTO); contract.propose_owner(account("new-owner.near")); - context(&account("someone-else.near")); + context_with_deposit(&account("someone-else.near"), ONE_YOCTO); contract.accept_owner(); } diff --git a/contract/vault/soroban/README.md b/contract/vault/soroban/README.md index 70a6ea0ad..e866ec72e 100644 --- a/contract/vault/soroban/README.md +++ b/contract/vault/soroban/README.md @@ -358,19 +358,26 @@ routing supplied assets into the HOT Stellar locker. Use recipes in [contract/vault/soroban/justfile](./justfile): - `just build-hot-bridge-adapter` -- `HOT_STELLAR_LOCKER_CONTRACT= HOT_STELLAR_RECEIVER_HEX=<64 hex chars> just deploy-hot-bridge-adapter` +- `SOROBAN_ASSET_TOKEN= HOT_STELLAR_LOCKER_CONTRACT= HOT_STELLAR_RECEIVER_HEX=<64 hex chars> just deploy-hot-bridge-adapter` - `just hot-bridge-adapter-status` `HOT_STELLAR_RECEIVER_HEX` is intentionally explicit. It should be copied from a proven HOT receiver for the NEAR counterparty, not recomputed locally during deployment. +`SOROBAN_ASSET_TOKEN` pins the single token this adapter will accept; if unset, the deploy recipe +uses `state/asset_token_id` from `deploy-test-token`. `deploy-hot-bridge-adapter` pins the production HOT Stellar locker by contract ID and fetched WASM SHA-256 before deploying. For non-production testing only, set `HOT_STELLAR_LOCKER_ALLOW_UNPINNED=1` and override `HOT_STELLAR_LOCKER_EXPECTED_WASM_SHA256` for the test locker. -Withdrawal settlement is observable on Soroban only when returned Stellar-side token balance reaches -the adapter. Without a NEAR/HOT proof verifier, operators must treat the HOT bridge/relayer path as a -trusted settlement dependency before calling `progress_withdrawal`. +Supply now pulls freshly authorized funds from the configured vault during the adapter call and +rejects pre-existing adapter balances. Returned HOT funds must be recorded by the configured HOT +locker with `record_returned` before the vault can call `progress_withdrawal`; raw adapter token +balance alone is not treated as settlement proof. + +The HOT locker deposit field named `client_timestamp` is treated by the adapter as a client nonce. +The pinned locker rejects duplicate values and also rejects same-ledger values above the ledger-base +timestamp, so same-ledger deposits use the next lower unused nonce for locker compatibility. ## Deployment Artifact diff --git a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs index 1bae5d90c..1a3af4d11 100644 --- a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -11,18 +11,39 @@ use stellar_contract_utils::upgradeable::{self, Upgradeable}; const INSTANCE_TTL_THRESHOLD: u32 = 518_400; const INSTANCE_TTL_EXTEND_TO: u32 = 3_110_400; const HOT_TIMESTAMP_SCALE: u128 = 1_000_000_000_000; +const STORAGE_VERSION: u32 = 1; +const ADMIN_TRANSFER_TTL_LEDGERS: u32 = 172_800; #[contracttype] #[derive(Clone, Debug)] enum DataKey { + StorageVersion, Admin, - PendingAdmin, + PendingAdminTransfer, Vault, HotLocker, HotReceiverId, - Paused, + Asset, + OperationalState, LastHotClientTimestamp, Principal(Address), + Returned(Address), +} + +#[contracttype] +#[derive(Clone, Debug)] +struct PendingAdminTransfer { + candidate: Address, + proposed_by: Address, + proposed_at: u32, + expires_at: u32, +} + +#[contracttype] +#[derive(Clone, Debug)] +enum OperationalState { + Active, + Paused(Address, u32), } #[contracterror] @@ -40,6 +61,11 @@ pub enum AdapterError { InsufficientAdapterBalance = 9, DepositPostconditionFailed = 10, HotClientTimestampExhausted = 11, + UnsupportedAsset = 12, + PendingReturnedBalance = 13, + InsufficientRecordedReturn = 14, + InsufficientSurplus = 15, + PendingAdminExpired = 16, } #[contract] @@ -52,18 +78,24 @@ impl HotBridgeAdapterContract { admin: Address, vault: Address, hot_locker: Address, + asset: Address, hot_receiver_id: BytesN<32>, ) -> Result<(), AdapterError> { extend_instance_ttl(&env); require_contract_address(&admin, AdapterError::InvalidInput)?; require_contract_address(&vault, AdapterError::InvalidInput)?; require_contract_address(&hot_locker, AdapterError::InvalidInput)?; + require_contract_address(&asset, AdapterError::InvalidInput)?; + env.storage() + .instance() + .set(&DataKey::StorageVersion, &STORAGE_VERSION); env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Vault, &vault); env.storage() .instance() .set(&DataKey::HotLocker, &hot_locker); + env.storage().instance().set(&DataKey::Asset, &asset); env.storage() .instance() .set(&DataKey::HotReceiverId, &hot_receiver_id); @@ -73,7 +105,14 @@ impl HotBridgeAdapterContract { pub fn set_paused(env: Env, caller: Address, paused: bool) -> Result<(), AdapterError> { extend_instance_ttl(&env); require_admin_or_vault(&env, &caller)?; - env.storage().instance().set(&DataKey::Paused, &paused); + let state = if paused { + OperationalState::Paused(caller.clone(), env.ledger().sequence()) + } else { + OperationalState::Active + }; + env.storage() + .instance() + .set(&DataKey::OperationalState, &state); env.events() .publish((symbol_short!("paused"), caller), paused); Ok(()) @@ -94,6 +133,10 @@ impl HotBridgeAdapterContract { require_not_paused(&env)?; require_vault(&env, &caller)?; require_positive_amount(amount)?; + require_configured_asset(&env, &asset)?; + if returned_of(&env, &asset) > 0 { + return Err(AdapterError::PendingReturnedBalance); + } let adapter = env.current_contract_address(); let hot_locker = get_hot_locker(&env)?; @@ -101,10 +144,12 @@ impl HotBridgeAdapterContract { let client_timestamp = next_hot_locker_timestamp(&env)?; let amount_u128 = amount_as_u128(amount)?; let token = soroban_sdk::token::Client::new(&env, &asset); - let balance_before = token.balance(&adapter); - if balance_before < amount { + if token.balance(&adapter) != 0 { return Err(AdapterError::InsufficientAdapterBalance); } + let vault = get_vault(&env)?; + token.transfer(&vault, &adapter, &amount); + let balance_before = token.balance(&adapter); authorize_hot_deposit(&env, &hot_locker, &asset, &adapter, amount); @@ -120,7 +165,13 @@ impl HotBridgeAdapterContract { env.invoke_contract::(&hot_locker, &Symbol::new(&env, "deposit"), args); let balance_after = token.balance(&adapter); - validate_supply_balance_change(balance_before, balance_after, amount)?; + if let Err(err) = validate_supply_balance_change(balance_before, balance_after, amount) { + let leftover = token.balance(&adapter); + if leftover > 0 { + token.transfer(&adapter, &vault, &leftover); + } + return Err(err); + } record_hot_locker_timestamp(&env, client_timestamp); increase_principal(&env, &asset, amount)?; @@ -152,6 +203,7 @@ impl HotBridgeAdapterContract { require_not_paused(&env)?; require_vault(&env, &caller)?; require_positive_amount(amount)?; + require_configured_asset(&env, &asset)?; let principal = principal_of(&env, &asset); if principal < amount { @@ -161,9 +213,15 @@ impl HotBridgeAdapterContract { let adapter = env.current_contract_address(); let vault = get_vault(&env)?; let token = soroban_sdk::token::Client::new(&env, &asset); - validate_withdrawal_resources(principal, token.balance(&adapter), amount)?; + validate_withdrawal_resources( + principal, + returned_of(&env, &asset), + token.balance(&adapter), + amount, + )?; token.transfer(&adapter, &vault, &amount); + decrease_returned(&env, &asset, amount)?; decrease_principal(&env, &asset, amount)?; env.events() .publish((symbol_short!("withdraw"), asset), amount); @@ -172,6 +230,7 @@ impl HotBridgeAdapterContract { pub fn total_assets(env: Env, asset: Address) -> Result { extend_instance_ttl(&env); + require_configured_asset(&env, &asset)?; Ok(principal_of(&env, &asset)) } @@ -180,6 +239,12 @@ impl HotBridgeAdapterContract { principal_of(&env, &asset) } + pub fn returned_balance(env: Env, asset: Address) -> Result { + extend_instance_ttl(&env); + require_configured_asset(&env, &asset)?; + Ok(returned_of(&env, &asset)) + } + pub fn hot_receiver_id(env: Env) -> Result, AdapterError> { extend_instance_ttl(&env); get_hot_receiver_id(&env) @@ -200,6 +265,38 @@ impl HotBridgeAdapterContract { get_vault(&env) } + pub fn asset(env: Env) -> Result { + extend_instance_ttl(&env); + get_configured_asset(&env) + } + + pub fn storage_version(env: Env) -> u32 { + extend_instance_ttl(&env); + env.storage() + .instance() + .get(&DataKey::StorageVersion) + .unwrap_or(STORAGE_VERSION) + } + + pub fn set_asset(env: Env, caller: Address, asset: Address) -> Result<(), AdapterError> { + extend_instance_ttl(&env); + require_admin(&env, &caller)?; + require_contract_address(&asset, AdapterError::InvalidInput)?; + if let Ok(current) = get_configured_asset(&env) { + if current != asset { + let has_position = + principal_of(&env, ¤t) > 0 || returned_of(&env, ¤t) > 0; + if has_position { + return Err(AdapterError::InvalidInput); + } + } + } + env.storage().instance().set(&DataKey::Asset, &asset); + env.events() + .publish((symbol_short!("asset"), caller), asset); + Ok(()) + } + pub fn propose_admin( env: Env, caller: Address, @@ -208,9 +305,18 @@ impl HotBridgeAdapterContract { extend_instance_ttl(&env); require_admin(&env, &caller)?; require_contract_address(&new_admin, AdapterError::InvalidInput)?; + let transfer = PendingAdminTransfer { + candidate: new_admin.clone(), + proposed_by: caller.clone(), + proposed_at: env.ledger().sequence(), + expires_at: env + .ledger() + .sequence() + .saturating_add(ADMIN_TRANSFER_TTL_LEDGERS), + }; env.storage() .instance() - .set(&DataKey::PendingAdmin, &new_admin); + .set(&DataKey::PendingAdminTransfer, &transfer); env.events() .publish((symbol_short!("adm_prop"), caller), new_admin); Ok(()) @@ -219,23 +325,68 @@ impl HotBridgeAdapterContract { pub fn accept_admin(env: Env, caller: Address) -> Result<(), AdapterError> { extend_instance_ttl(&env); caller.require_auth(); - let pending: Address = env + let pending: PendingAdminTransfer = env .storage() .instance() - .get(&DataKey::PendingAdmin) + .get(&DataKey::PendingAdminTransfer) .ok_or(AdapterError::MissingConfig)?; - if caller != pending { + if env.ledger().sequence() > pending.expires_at { + return Err(AdapterError::PendingAdminExpired); + } + if caller != pending.candidate { return Err(AdapterError::Unauthorized); } env.storage().instance().set(&DataKey::Admin, &caller); - env.storage().instance().remove(&DataKey::PendingAdmin); + env.storage() + .instance() + .remove(&DataKey::PendingAdminTransfer); env.events().publish((symbol_short!("adm_acc"), caller), ()); Ok(()) } pub fn pending_admin(env: Env) -> Option
{ extend_instance_ttl(&env); - env.storage().instance().get(&DataKey::PendingAdmin) + env.storage() + .instance() + .get::<_, PendingAdminTransfer>(&DataKey::PendingAdminTransfer) + .map(|transfer| transfer.candidate) + } + + pub fn cancel_admin_transfer(env: Env, caller: Address) -> Result<(), AdapterError> { + extend_instance_ttl(&env); + require_admin(&env, &caller)?; + env.storage() + .instance() + .remove(&DataKey::PendingAdminTransfer); + env.events().publish((symbol_short!("adm_can"), caller), ()); + Ok(()) + } + + pub fn record_returned( + env: Env, + caller: Address, + asset: Address, + amount: i128, + ) -> Result<(), AdapterError> { + extend_instance_ttl(&env); + require_not_paused(&env)?; + require_hot_locker(&env, &caller)?; + require_positive_amount(amount)?; + require_configured_asset(&env, &asset)?; + let principal = principal_of(&env, &asset); + let returned = returned_of(&env, &asset); + let updated = returned + .checked_add(amount) + .ok_or(AdapterError::ArithmeticOverflow)?; + if updated > principal { + return Err(AdapterError::InsufficientPrincipal); + } + env.storage() + .instance() + .set(&DataKey::Returned(asset.clone()), &updated); + env.events() + .publish((symbol_short!("returned"), asset), amount); + Ok(()) } pub fn rescue( @@ -249,13 +400,23 @@ impl HotBridgeAdapterContract { require_not_paused(&env)?; require_vault(&env, &caller)?; require_positive_amount(amount)?; + require_configured_asset(&env, &asset)?; require_contract_address(&receiver, AdapterError::InvalidInput)?; if receiver == env.current_contract_address() { return Err(AdapterError::InvalidInput); } let adapter = env.current_contract_address(); - soroban_sdk::token::Client::new(&env, &asset).transfer(&adapter, &receiver, &amount); + let token = soroban_sdk::token::Client::new(&env, &asset); + let balance = token.balance(&adapter); + let reserved = returned_of(&env, &asset); + let surplus = balance + .checked_sub(reserved) + .ok_or(AdapterError::InsufficientSurplus)?; + if amount > surplus { + return Err(AdapterError::InsufficientSurplus); + } + token.transfer(&adapter, &receiver, &amount); env.events() .publish((symbol_short!("rescue"), asset, receiver), amount); Ok(()) @@ -354,12 +515,16 @@ fn validate_supply_balance_change( fn validate_withdrawal_resources( principal: i128, + recorded_returned: i128, adapter_balance: i128, amount: i128, ) -> Result<(), AdapterError> { if principal < amount { return Err(AdapterError::InsufficientPrincipal); } + if recorded_returned < amount { + return Err(AdapterError::InsufficientRecordedReturn); + } if adapter_balance < amount { return Err(AdapterError::InsufficientReturnedBalance); } @@ -408,6 +573,30 @@ fn decrease_principal(env: &Env, asset: &Address, amount: i128) -> Result<(), Ad Ok(()) } +fn returned_of(env: &Env, asset: &Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Returned(asset.clone())) + .unwrap_or(0) +} + +fn decrease_returned(env: &Env, asset: &Address, amount: i128) -> Result<(), AdapterError> { + let current = returned_of(env, asset); + if current < amount { + return Err(AdapterError::InsufficientRecordedReturn); + } + let updated = current + .checked_sub(amount) + .ok_or(AdapterError::ArithmeticUnderflow)?; + let key = DataKey::Returned(asset.clone()); + if updated == 0 { + env.storage().instance().remove(&key); + } else { + env.storage().instance().set(&key, &updated); + } + Ok(()) +} + fn get_admin(env: &Env) -> Result { env.storage() .instance() @@ -436,6 +625,21 @@ fn get_hot_receiver_id(env: &Env) -> Result, AdapterError> { .ok_or(AdapterError::MissingConfig) } +fn get_configured_asset(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Asset) + .ok_or(AdapterError::MissingConfig) +} + +fn require_configured_asset(env: &Env, asset: &Address) -> Result<(), AdapterError> { + if asset == &get_configured_asset(env)? { + Ok(()) + } else { + Err(AdapterError::UnsupportedAsset) + } +} + fn require_admin(env: &Env, caller: &Address) -> Result<(), AdapterError> { caller.require_auth(); if caller != &get_admin(env)? { @@ -452,6 +656,14 @@ fn require_vault(env: &Env, caller: &Address) -> Result<(), AdapterError> { Ok(()) } +fn require_hot_locker(env: &Env, caller: &Address) -> Result<(), AdapterError> { + caller.require_auth(); + if caller != &get_hot_locker(env)? { + return Err(AdapterError::Unauthorized); + } + Ok(()) +} + fn require_admin_or_vault(env: &Env, caller: &Address) -> Result<(), AdapterError> { caller.require_auth(); let admin = get_admin(env)?; @@ -463,10 +675,13 @@ fn require_admin_or_vault(env: &Env, caller: &Address) -> Result<(), AdapterErro } fn is_paused(env: &Env) -> bool { - env.storage() - .instance() - .get(&DataKey::Paused) - .unwrap_or(false) + matches!( + env.storage() + .instance() + .get(&DataKey::OperationalState) + .unwrap_or(OperationalState::Active), + OperationalState::Paused(_, _) + ) } fn require_not_paused(env: &Env) -> Result<(), AdapterError> { @@ -533,7 +748,7 @@ mod kani_proofs { assert_eq!(selected, Err(AdapterError::HotClientTimestampExhausted)); } Some(last) => { - let selected = selected.expect("last > 0 can always step down"); + let selected = selected.expect("last > 0 can step down"); assert_eq!(selected, last - 1); assert!(selected < last); assert_ne!(selected, last); @@ -650,10 +865,16 @@ mod kani_proofs { kani::assume(adapter_balance >= 0); kani::assume(amount > 0); - let result = validate_withdrawal_resources(principal, adapter_balance, amount); + let recorded_returned: i128 = kani::any(); + kani::assume(recorded_returned >= 0); + + let result = + validate_withdrawal_resources(principal, recorded_returned, adapter_balance, amount); if principal < amount { assert_eq!(result, Err(AdapterError::InsufficientPrincipal)); + } else if recorded_returned < amount { + assert_eq!(result, Err(AdapterError::InsufficientRecordedReturn)); } else if adapter_balance < amount { assert_eq!(result, Err(AdapterError::InsufficientReturnedBalance)); } else { @@ -778,7 +999,7 @@ mod tests { ); let adapter = env.register( HotBridgeAdapterContract, - (&admin, &vault, &hot_locker, &receiver), + (&admin, &vault, &hot_locker, &asset, &receiver), ); (adapter, admin, vault, hot_locker, asset, receiver) } @@ -892,7 +1113,7 @@ mod tests { &mut last_hot_timestamp, ); - asset_admin.mint(&adapter, &amount); + asset_admin.mint(&vault, &amount); client.supply(&vault, &asset, &amount); model_principal += amount; model_locker_balance += amount; @@ -905,9 +1126,10 @@ mod tests { if amount > model_principal { prop_assert_eq!(result, Err(Ok(AdapterError::InsufficientPrincipal))); } else { - prop_assert_eq!(result, Err(Ok(AdapterError::InsufficientReturnedBalance))); + prop_assert_eq!(result, Err(Ok(AdapterError::InsufficientRecordedReturn))); asset_admin.mint(&adapter, &amount); + client.record_returned(&hot_locker, &asset, &amount); prop_assert_eq!(client.progress_withdrawal(&vault, &asset, &amount), amount); model_principal -= amount; model_vault_balance += amount; @@ -934,15 +1156,16 @@ mod tests { let receiver = BytesN::from_array(&env, &[7; 32]); let adapter = env.register( HotBridgeAdapterContract, - (&admin, &vault, &hot_locker, &receiver), + (&admin, &vault, &hot_locker, &asset, &receiver), ); let client = HotBridgeAdapterContractClient::new(&env, &adapter); let token = soroban_sdk::token::Client::new(&env, &asset); - StellarAssetClient::new(&env, &asset).mint(&adapter, &amount); + StellarAssetClient::new(&env, &asset).mint(&vault, &amount); prop_assert_eq!(client.try_supply(&vault, &asset, &amount), Err(Ok(AdapterError::DepositPostconditionFailed))); prop_assert_eq!(client.total_assets(&asset), 0); - prop_assert_eq!(token.balance(&adapter), amount); + prop_assert_eq!(token.balance(&adapter), 0); + prop_assert_eq!(token.balance(&vault), amount); prop_assert_eq!(token.balance(&hot_locker), 0); } } @@ -953,7 +1176,7 @@ mod tests { let (adapter, _admin, vault, hot_locker, asset, receiver) = setup(&env); let client = HotBridgeAdapterContractClient::new(&env, &adapter); let asset_admin = StellarAssetClient::new(&env, &asset); - asset_admin.mint(&adapter, &100); + asset_admin.mint(&vault, &100); client.supply(&vault, &asset, &100); assert_hot_deposit_event( @@ -989,7 +1212,7 @@ mod tests { let asset_admin = StellarAssetClient::new(&env, &asset); let base_nonce = 1_777_000_000_000_000_000_000; - asset_admin.mint(&adapter, &100); + asset_admin.mint(&vault, &100); client.supply(&vault, &asset, &100); assert_hot_deposit_event( &env, @@ -1001,7 +1224,7 @@ mod tests { base_nonce, ); - asset_admin.mint(&adapter, &60); + asset_admin.mint(&vault, &60); client.supply(&vault, &asset, &60); assert_hot_deposit_event( &env, @@ -1015,7 +1238,7 @@ mod tests { assert_eq!(client.total_assets(&asset), 160); env.ledger().set_timestamp(1_777_000_001); - asset_admin.mint(&adapter, &25); + asset_admin.mint(&vault, &25); client.supply(&vault, &asset, &25); assert_hot_deposit_event( @@ -1036,10 +1259,11 @@ mod tests { let (adapter, _admin, vault, _hot_locker, asset, _receiver) = setup(&env); let client = HotBridgeAdapterContractClient::new(&env, &adapter); let asset_admin = StellarAssetClient::new(&env, &asset); - asset_admin.mint(&adapter, &100); + asset_admin.mint(&vault, &100); client.supply(&vault, &asset, &100); asset_admin.mint(&adapter, &40); + client.record_returned(&_hot_locker, &asset, &40); assert_eq!(client.progress_withdrawal(&vault, &asset, &40), 40); assert_eq!(client.total_assets(&asset), 60); @@ -1055,11 +1279,107 @@ mod tests { let (adapter, _admin, vault, _hot_locker, asset, _receiver) = setup(&env); let client = HotBridgeAdapterContractClient::new(&env, &adapter); let asset_admin = StellarAssetClient::new(&env, &asset); - asset_admin.mint(&adapter, &100); + asset_admin.mint(&vault, &100); client.supply(&vault, &asset, &100); let error = client.try_progress_withdrawal(&vault, &asset, &1); - assert_eq!(error, Err(Ok(AdapterError::InsufficientReturnedBalance))); + assert_eq!(error, Err(Ok(AdapterError::InsufficientRecordedReturn))); + } + + #[test] + fn supply_rejects_redeploying_returned_balance() { + let env = Env::default(); + let (adapter, _admin, vault, hot_locker, asset, _receiver) = setup(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let asset_admin = StellarAssetClient::new(&env, &asset); + asset_admin.mint(&vault, &100); + client.supply(&vault, &asset, &100); + + asset_admin.mint(&adapter, &40); + client.record_returned(&hot_locker, &asset, &40); + asset_admin.mint(&vault, &40); + + let error = client.try_supply(&vault, &asset, &40); + + assert_eq!(error, Err(Ok(AdapterError::PendingReturnedBalance))); + assert_eq!(client.total_assets(&asset), 100); + assert_eq!(client.returned_balance(&asset), 40); + assert_eq!( + soroban_sdk::token::Client::new(&env, &asset).balance(&adapter), + 40 + ); + } + + #[test] + fn supply_rejects_unsolicited_adapter_balance() { + let env = Env::default(); + let (adapter, _admin, vault, _hot_locker, asset, _receiver) = setup(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let asset_admin = StellarAssetClient::new(&env, &asset); + asset_admin.mint(&vault, &100); + client.supply(&vault, &asset, &100); + + asset_admin.mint(&adapter, &40); + asset_admin.mint(&vault, &40); + + let error = client.try_supply(&vault, &asset, &40); + + assert_eq!(error, Err(Ok(AdapterError::InsufficientAdapterBalance))); + assert_eq!(client.total_assets(&asset), 100); + } + + #[test] + fn progress_withdrawal_rejects_unsolicited_balance_without_return_record() { + let env = Env::default(); + let (adapter, _admin, vault, _hot_locker, asset, _receiver) = setup(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let asset_admin = StellarAssetClient::new(&env, &asset); + asset_admin.mint(&vault, &100); + client.supply(&vault, &asset, &100); + + asset_admin.mint(&adapter, &50); + + let error = client.try_progress_withdrawal(&vault, &asset, &50); + + assert_eq!(error, Err(Ok(AdapterError::InsufficientRecordedReturn))); + assert_eq!(client.total_assets(&asset), 100); + } + + #[test] + fn rescue_cannot_transfer_principal_backing_returned_balance() { + let env = Env::default(); + let (adapter, _admin, vault, hot_locker, asset, _receiver) = setup(&env); + let receiver = register_dummy_contract(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let asset_admin = StellarAssetClient::new(&env, &asset); + asset_admin.mint(&vault, &100); + client.supply(&vault, &asset, &100); + asset_admin.mint(&adapter, &40); + client.record_returned(&hot_locker, &asset, &40); + + let error = client.try_rescue(&vault, &asset, &40, &receiver); + + assert_eq!(error, Err(Ok(AdapterError::InsufficientSurplus))); + assert_eq!(client.total_assets(&asset), 100); + assert_eq!(client.returned_balance(&asset), 40); + assert_eq!( + soroban_sdk::token::Client::new(&env, &asset).balance(&adapter), + 40 + ); + } + + #[test] + fn supply_rejects_unsupported_asset() { + let env = Env::default(); + let (adapter, _admin, vault, _hot_locker, _configured_asset, _receiver) = setup(&env); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let unsupported_sac = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let unsupported_asset = unsupported_sac.address(); + StellarAssetClient::new(&env, &unsupported_asset).mint(&vault, &1); + + let error = client.try_supply(&vault, &unsupported_asset, &1); + + assert_eq!(error, Err(Ok(AdapterError::UnsupportedAsset))); } #[test] @@ -1074,15 +1394,23 @@ mod tests { let receiver = BytesN::from_array(&env, &[7; 32]); let adapter = env.register( HotBridgeAdapterContract, - (&admin, &vault, &hot_locker, &receiver), + (&admin, &vault, &hot_locker, &asset, &receiver), ); let client = HotBridgeAdapterContractClient::new(&env, &adapter); - StellarAssetClient::new(&env, &asset).mint(&adapter, &100); + StellarAssetClient::new(&env, &asset).mint(&vault, &100); let error = client.try_supply(&vault, &asset, &100); assert_eq!(error, Err(Ok(AdapterError::DepositPostconditionFailed))); assert_eq!(client.total_assets(&asset), 0); + assert_eq!( + soroban_sdk::token::Client::new(&env, &asset).balance(&adapter), + 0 + ); + assert_eq!( + soroban_sdk::token::Client::new(&env, &asset).balance(&vault), + 100 + ); } #[test] diff --git a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs index 37af1382c..7b1d21116 100644 --- a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs +++ b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs @@ -202,10 +202,10 @@ fn adapter_supply_works_against_mainnet_locker_wasm() { let receiver = proven_receiver(&env); let adapter = env.register( HotBridgeAdapterContract, - (&admin, &vault, &locker, &receiver), + (&admin, &vault, &locker, &token, &receiver), ); let adapter_client = HotBridgeAdapterContractClient::new(&env, &adapter); - StellarAssetClient::new(&env, &token).mint(&adapter, &100); + StellarAssetClient::new(&env, &token).mint(&vault, &100); adapter_client.supply(&vault, &token, &100); assert_hot_deposit_event( @@ -243,17 +243,17 @@ fn adapter_supply_supports_two_deposits_in_same_ledger_against_mainnet_locker_wa let receiver = proven_receiver(&env); let adapter = env.register( HotBridgeAdapterContract, - (&admin, &vault, &locker, &receiver), + (&admin, &vault, &locker, &token, &receiver), ); let adapter_client = HotBridgeAdapterContractClient::new(&env, &adapter); - StellarAssetClient::new(&env, &token).mint(&adapter, &100); + StellarAssetClient::new(&env, &token).mint(&vault, &100); adapter_client.supply(&vault, &token, &100); let first_nonce = 1_777_000_000_000_000_000_000; let second_nonce = first_nonce - 1; assert_hot_deposit_event(&env, &adapter, &token, &locker, &receiver, 100, first_nonce); - StellarAssetClient::new(&env, &token).mint(&adapter, &60); + StellarAssetClient::new(&env, &token).mint(&vault, &60); adapter_client.supply(&vault, &token, &60); assert_hot_deposit_event(&env, &adapter, &token, &locker, &receiver, 60, second_nonce); assert!(!get_locker_deposit(&env, &locker, first_nonce).is_empty()); diff --git a/contract/vault/soroban/justfile b/contract/vault/soroban/justfile index 03cdbd05e..0de06cff8 100644 --- a/contract/vault/soroban/justfile +++ b/contract/vault/soroban/justfile @@ -582,6 +582,11 @@ deploy-hot-bridge-adapter: admin="$vault_id"; \ fi; \ hot_locker="${HOT_STELLAR_LOCKER_CONTRACT:?set HOT_STELLAR_LOCKER_CONTRACT}"; \ + asset_token="${SOROBAN_ASSET_TOKEN:-$(cat {{state_dir}}/asset_token_id 2>/dev/null)}"; \ + if [ -z "$asset_token" ]; then \ + echo "Error: No asset token. Run 'just deploy-test-token' first or set SOROBAN_ASSET_TOKEN"; \ + exit 1; \ + fi; \ hot_receiver_id="${HOT_STELLAR_RECEIVER_HEX:?set HOT_STELLAR_RECEIVER_HEX to the proven HOT receiver bytes}"; \ expected_locker="${HOT_STELLAR_LOCKER_EXPECTED_CONTRACT:-{{hot_stellar_locker_expected_contract}}}"; \ expected_hash="${HOT_STELLAR_LOCKER_EXPECTED_WASM_SHA256:-{{hot_stellar_locker_expected_wasm_sha256}}}"; \ @@ -618,6 +623,7 @@ deploy-hot-bridge-adapter: echo " Admin: $admin"; \ echo " Vault: $vault_id"; \ echo " HOT locker: $hot_locker"; \ + echo " Asset token: $asset_token"; \ echo " Locker hash: $actual_hash"; \ echo " HOT receiver: $hot_receiver_id"; \ adapter_id=$(stellar contract deploy \ @@ -628,6 +634,7 @@ deploy-hot-bridge-adapter: --admin "$admin" \ --vault "$vault_id" \ --hot-locker "$hot_locker" \ + --asset "$asset_token" \ --hot-receiver-id "$hot_receiver_id"); \ if [ -z "$adapter_id" ]; then \ echo "Error: deploy failed — no adapter ID returned"; \ From fdf2dccb4fa62ff1b16953113cc974edcff2e307 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 8 Jun 2026 11:30:11 +0200 Subject: [PATCH 14/18] fix: address HOT bridge PR review feedback --- .github/workflows/test.yml | 4 +- contract/vault-counterparty/justfile | 3 +- .../scripts/monitor-withdrawal.sh | 38 ++ contract/vault-counterparty/src/lib.rs | 42 +- .../soroban/hot-bridge-adapter/README.md | 3 + .../soroban/hot-bridge-adapter/src/lib.rs | 21 +- .../tests/mainnet_locker_wasm.rs | 12 +- rust-toolchain.toml | 2 +- script/prebuild-test-contracts.sh | 27 +- service/hot-relayer/Dockerfile | 6 + service/hot-relayer/README.md | 4 +- .../scripts/complete_hot_deposit.sh | 4 +- service/hot-relayer/src/bridge_transport.rs | 37 +- service/hot-relayer/src/config.rs | 54 +- service/hot-relayer/src/hot_relayer.rs | 551 +++++++++++------- service/hot-relayer/src/main.rs | 2 +- service/hot-relayer/src/routes.rs | 67 ++- 17 files changed, 588 insertions(+), 289 deletions(-) create mode 100755 contract/vault-counterparty/scripts/monitor-withdrawal.sh create mode 100644 contract/vault/soroban/hot-bridge-adapter/README.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 625b92f0b..e412ec8b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -410,8 +410,8 @@ jobs: CARGO_TERM_COLOR: always run: | # --lib: unit tests only (excludes slow WASM integration tests). - # Soroban crates are tested in the dedicated Soroban job with their - # newer toolchain; exclude them from the default 1.86 coverage build. + # Soroban crates are tested in the dedicated Soroban job; exclude them + # from the default workspace coverage build. cargo llvm-cov nextest --workspace --lib --lcov --output-path coverage.lcov \ --exclude templar-soroban-blend-adapter \ --exclude templar-soroban-governance \ diff --git a/contract/vault-counterparty/justfile b/contract/vault-counterparty/justfile index 29353ba92..03a9a0c46 100644 --- a/contract/vault-counterparty/justfile +++ b/contract/vault-counterparty/justfile @@ -194,8 +194,7 @@ balances: just balance "$NEAR_MARKET" monitor-withdrawal nonce receiver='' interval='15': check-monitor-tools - RECEIVER="{{receiver}}"; if [ -z "$RECEIVER" ]; then RECEIVER="${STELLAR_RECEIVER:?set STELLAR_RECEIVER or pass receiver}"; fi - while true; do echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') ====="; echo "nonce: {{nonce}}"; echo "receiver: $RECEIVER"; echo "-- stellar native balance --"; python3 -c 'import json, sys, urllib.request; addr = sys.argv[1]; data = json.load(urllib.request.urlopen(f"https://horizon.stellar.org/accounts/{addr}", timeout=30)); [print(balance.get("balance")) for balance in data.get("balances", []) if balance.get("asset_type") == "native"]' "$RECEIVER" || true; echo "-- hot withdraw/sign --"; node -e 'const nonce = process.argv[1]; for (const url of ["https://rpc1.hotdao.ai/withdraw/sign", "https://rpc2.hotdao.ai/withdraw/sign"]) { try { const res = await fetch(url, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({nonce})}); console.log(url, res.status, await res.text()); } catch (error) { console.log(url, "ERR", String(error)); } }' "{{nonce}}"; echo "-- hot clear_completed_withdrawal --"; node -e 'const nonce = process.argv[1]; const headers = {"omni-version": "v2", "Content-Type": "application/json", "Referer": "https://near-intents.org", "Origin": "https://near-intents.org"}; for (const url of ["https://api0.herewallet.app/api/v1/transactions/clear_completed_withdrawal", "https://api2.herewallet.app/api/v1/transactions/clear_completed_withdrawal"]) { try { const res = await fetch(url, {method: "POST", headers, body: JSON.stringify({nonce})}); console.log(url, res.status, await res.text()); } catch (error) { console.log(url, "ERR", String(error)); } }' "{{nonce}}"; if [ -n "${HOT_STELLAR_ENCODED_RECEIVER:-}" ]; then echo "-- near pending withdrawals --"; python3 -c 'import base64, json, sys, urllib.request; encoded, account_id, rpc_url = sys.argv[1:]; args = {"receiver_id": encoded, "chain_id": 1100}; payload = {"jsonrpc": "2.0", "id": "dontcare", "method": "query", "params": {"request_type": "call_function", "finality": "final", "account_id": account_id, "method_name": "get_withdrawals_by_receiver", "args_base64": base64.b64encode(json.dumps(args).encode()).decode()}}; request = urllib.request.Request(rpc_url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}); print(urllib.request.urlopen(request, timeout=60).read().decode())' "$HOT_STELLAR_ENCODED_RECEIVER" "{{OMNI_CONTRACT}}" "{{HOT_OMNI_NEAR_RPC_URL}}" || true; else echo "-- near pending withdrawals skipped; set HOT_STELLAR_ENCODED_RECEIVER to query by HOT receiver id --"; fi; if [ "${ONCE:-0}" = "1" ]; then break; fi; sleep "{{interval}}"; done + bash contract/vault-counterparty/scripts/monitor-withdrawal.sh "{{nonce}}" "{{receiver}}" "{{interval}}" # Fast synthetic smoke path: funds counterparty by direct NEAR mt_transfer. smoke-synthetic fund_amount='1000' forward_amount='100' market_withdraw_amount='50' stellar_withdraw_amount='50': check-env diff --git a/contract/vault-counterparty/scripts/monitor-withdrawal.sh b/contract/vault-counterparty/scripts/monitor-withdrawal.sh new file mode 100755 index 000000000..fcd8113e2 --- /dev/null +++ b/contract/vault-counterparty/scripts/monitor-withdrawal.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +nonce="${1:?usage: monitor-withdrawal.sh [receiver] [interval]}" +receiver="${2:-}" +interval="${3:-15}" + +if [ -z "$receiver" ]; then + receiver="${STELLAR_RECEIVER:?set STELLAR_RECEIVER or pass receiver}" +fi + +while true; do + echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') =====" + echo "nonce: $nonce" + echo "receiver: $receiver" + + echo "-- stellar native balance --" + python3 -c 'import json, sys, urllib.request; addr = sys.argv[1]; data = json.load(urllib.request.urlopen(f"https://horizon.stellar.org/accounts/{addr}", timeout=30)); [print(balance.get("balance")) for balance in data.get("balances", []) if balance.get("asset_type") == "native"]' "$receiver" || true + + echo "-- hot withdraw/sign --" + node -e 'const nonce = process.argv[1]; for (const url of ["https://rpc1.hotdao.ai/withdraw/sign", "https://rpc2.hotdao.ai/withdraw/sign"]) { try { const res = await fetch(url, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({nonce})}); console.log(url, res.status, await res.text()); } catch (error) { console.log(url, "ERR", String(error)); } }' "$nonce" + + echo "-- hot clear_completed_withdrawal --" + node -e 'const nonce = process.argv[1]; const headers = {"omni-version": "v2", "Content-Type": "application/json", "Referer": "https://near-intents.org", "Origin": "https://near-intents.org"}; for (const url of ["https://api0.herewallet.app/api/v1/transactions/clear_completed_withdrawal", "https://api2.herewallet.app/api/v1/transactions/clear_completed_withdrawal"]) { try { const res = await fetch(url, {method: "POST", headers, body: JSON.stringify({nonce})}); console.log(url, res.status, await res.text()); } catch (error) { console.log(url, "ERR", String(error)); } }' "$nonce" + + if [ -n "${HOT_STELLAR_ENCODED_RECEIVER:-}" ]; then + echo "-- near pending withdrawals --" + python3 -c 'import base64, json, sys, urllib.request; encoded, account_id, rpc_url = sys.argv[1:]; args = {"receiver_id": encoded, "chain_id": 1100}; payload = {"jsonrpc": "2.0", "id": "dontcare", "method": "query", "params": {"request_type": "call_function", "finality": "final", "account_id": account_id, "method_name": "get_withdrawals_by_receiver", "args_base64": base64.b64encode(json.dumps(args).encode()).decode()}}; request = urllib.request.Request(rpc_url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}); print(urllib.request.urlopen(request, timeout=60).read().decode())' "$HOT_STELLAR_ENCODED_RECEIVER" "${OMNI_CONTRACT:?set OMNI_CONTRACT}" "${HOT_OMNI_NEAR_RPC_URL:?set HOT_OMNI_NEAR_RPC_URL}" || true + else + echo "-- near pending withdrawals skipped; set HOT_STELLAR_ENCODED_RECEIVER to query by HOT receiver id --" + fi + + if [ "${ONCE:-0}" = "1" ]; then + break + fi + sleep "$interval" +done diff --git a/contract/vault-counterparty/src/lib.rs b/contract/vault-counterparty/src/lib.rs index 00ea95a30..e663c848c 100644 --- a/contract/vault-counterparty/src/lib.rs +++ b/contract/vault-counterparty/src/lib.rs @@ -97,7 +97,7 @@ impl OmniTokenId { format!("token_id too long, max {}", MAX_TOKEN_ID_LEN) ); - let wrapped_prefix = format!("nep245:{}:", omni_contract); + let wrapped_prefix = format!("nep245:{omni_contract}:"); let normalized = token_id .strip_prefix(&wrapped_prefix) .unwrap_or(&token_id) @@ -189,6 +189,7 @@ pub struct Operation { #[derive(PanicOnDefault)] #[near(contract_state)] +#[allow(clippy::struct_field_names)] pub struct Contract { schema_version: u32, stellar_receiver: StellarReceiver, @@ -212,6 +213,7 @@ pub struct Contract { #[near] impl Contract { #[init] + #[allow(clippy::too_many_arguments)] pub fn new( stellar_receiver: String, near_market: AccountId, @@ -338,6 +340,7 @@ impl Contract { .remove(&env::predecessor_account_id()); } + #[allow(clippy::used_underscore_binding)] pub fn execute_next_supply_withdrawal_request( &mut self, _batch_limit: Option, @@ -373,7 +376,7 @@ impl Contract { self.assert_not_paused(); self.assert_curator(); Self::assert_amount(amount); - self.assert_withdraw_gas_budget(); + Self::assert_withdraw_gas_budget(); let operation_id = self.start_operation(OperationKind::WithdrawToStellar, amount.0); self.hot_withdraw_promise(amount, operation_id).then( @@ -391,7 +394,7 @@ impl Contract { amounts: Vec, msg: String, ) -> PromiseOrValue> { - let _authorized_sender = sender_id; + let _ = sender_id; require!( env::predecessor_account_id() == self.intents_integration.contract, @@ -409,11 +412,11 @@ impl Contract { let amount = amounts[0]; Self::assert_amount(amount); + self.assert_not_paused(); + Self::assert_withdraw_gas_budget(); let supplier = previous_owner_ids[0].clone(); self.increase_supply_position(supplier, amount.0); - self.assert_not_paused(); - self.assert_withdraw_gas_budget(); let operation_id = self.start_operation(OperationKind::WithdrawToStellar, amount.0); self.hot_withdraw_promise(amount, operation_id) .then( @@ -570,7 +573,7 @@ impl Contract { let kind = operation.kind; let amount = operation.amount; if kind == OperationKind::WithdrawToStellar { - self.assert_withdraw_gas_budget(); + Self::assert_withdraw_gas_budget(); } let operation = self @@ -730,7 +733,7 @@ impl Contract { pub fn propose_config_update(&mut self, update: ConfigUpdate) { assert_one_yocto(); self.assert_owner(); - self.validate_config_update(&update); + Self::validate_config_update(&update); self.pending_config_update = Some(update); env::log_str("config_update_proposed"); } @@ -743,7 +746,7 @@ impl Contract { .pending_config_update .take() .unwrap_or_else(|| env::panic_str("no pending config update")); - self.validate_config_update(&update); + Self::validate_config_update(&update); self.stellar_receiver = StellarReceiver::new(update.stellar_receiver); self.near_market = update.near_market; self.omni_token_id = OmniTokenId::new(update.omni_token_id, &update.omni_contract); @@ -756,7 +759,7 @@ impl Contract { pub fn propose_intents_integration(&mut self, integration: IntentsIntegration) { assert_one_yocto(); self.assert_owner(); - self.validate_intents_integration(&integration); + Self::validate_intents_integration(&integration); self.pending_intents_integration = Some(integration); env::log_str("intents_integration_proposed"); } @@ -769,7 +772,7 @@ impl Contract { .pending_intents_integration .take() .unwrap_or_else(|| env::panic_str("no pending intents integration")); - self.validate_intents_integration(&integration); + Self::validate_intents_integration(&integration); self.intents_integration = integration; env::log_str("intents_integration_accepted"); } @@ -811,7 +814,7 @@ impl Contract { require!(!self.paused, "contract is paused"); } - fn assert_withdraw_gas_budget(&self) { + fn assert_withdraw_gas_budget() { let available = env::prepaid_gas() .as_gas() .saturating_sub(env::used_gas().as_gas()); @@ -826,7 +829,7 @@ impl Contract { require!(amount.0 > 0, "amount must be > 0"); } - fn validate_config_update(&self, update: &ConfigUpdate) { + fn validate_config_update(update: &ConfigUpdate) { let _stellar_receiver = StellarReceiver::new(update.stellar_receiver.clone()); let _omni_token_id = OmniTokenId::new(update.omni_token_id.clone(), &update.omni_contract); let _hot_deposit_receiver = @@ -837,7 +840,7 @@ impl Contract { ); } - fn validate_intents_integration(&self, integration: &IntentsIntegration) { + fn validate_intents_integration(integration: &IntentsIntegration) { require!( integration.contract != env::current_account_id(), "intents contract cannot be this contract" @@ -872,7 +875,7 @@ mod tests { fn account(account_id: &str) -> AccountId { account_id .parse() - .unwrap_or_else(|_| panic!("invalid account id: {account_id}")) + .unwrap_or_else(|_| panic!("invalid test account id")) } fn context(predecessor: &AccountId) { @@ -974,7 +977,7 @@ mod tests { if input.trim().is_empty() || input.len() > MAX_TOKEN_ID_LEN { return None; } - let wrapped_prefix = format!("nep245:{}:", omni_contract); + let wrapped_prefix = format!("nep245:{omni_contract}:"); let normalized = input.strip_prefix(&wrapped_prefix).unwrap_or(input); if normalized.contains(':') || !normalized.starts_with(HOT_STELLAR_CHAIN_PREFIX) @@ -1017,7 +1020,7 @@ mod tests { && !token_id.starts_with("nep245:") && token_id.len() <= MAX_TOKEN_ID_LEN.saturating_sub("nep245:v2_1.omni.hot.tg:".len()) { - format!("nep245:{}:{token_id}", omni_contract) + format!("nep245:{omni_contract}:{token_id}") } else { token_id }; @@ -1041,7 +1044,10 @@ mod tests { prop_assert_eq!(contract.get_config().omni_token_id, normalized); prop_assert_eq!( contract.intents_wrapped_token_id(), - format!("nep245:{}:{}", omni_contract, contract.get_config().omni_token_id) + format!( + "nep245:{omni_contract}:{}", + contract.get_config().omni_token_id + ) ); } None => { @@ -1721,7 +1727,7 @@ mod tests { let _ = Contract::new( "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), account("templar-market.near"), - "".to_string(), + String::new(), account("v2_1.omni.hot.tg"), HOT_DEPOSIT_RECEIVER_HEX.to_string(), account("curator.near"), diff --git a/contract/vault/soroban/hot-bridge-adapter/README.md b/contract/vault/soroban/hot-bridge-adapter/README.md new file mode 100644 index 000000000..aa3cf8d1f --- /dev/null +++ b/contract/vault/soroban/hot-bridge-adapter/README.md @@ -0,0 +1,3 @@ +# HOT Bridge Adapter + +The adapter rejects `supply` while an asset has a pending returned balance. Operators must progress returned funds back to the vault before redeploying more of that asset. diff --git a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs index 1a3af4d11..1d28f766e 100644 --- a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -72,6 +72,7 @@ pub enum AdapterError { pub struct HotBridgeAdapterContract; #[contractimpl] +#[allow(deprecated)] impl HotBridgeAdapterContract { pub fn __constructor( env: Env, @@ -134,6 +135,9 @@ impl HotBridgeAdapterContract { require_vault(&env, &caller)?; require_positive_amount(amount)?; require_configured_asset(&env, &asset)?; + // Returned balances must be progressed back to the vault before new supply + // can bridge more of the same asset, otherwise principal and completed + // withdrawal accounting would be mixed. if returned_of(&env, &asset) > 0 { return Err(AdapterError::PendingReturnedBalance); } @@ -932,7 +936,7 @@ mod tests { .set(&Symbol::new(&env, "amount"), &amount_i128); soroban_sdk::token::Client::new(&env, &token).transfer( &sender_id, - &env.current_contract_address(), + env.current_contract_address(), &amount_i128, ); client_timestamp @@ -1004,6 +1008,10 @@ mod tests { (adapter, admin, vault, hot_locker, asset, receiver) } + fn expected_hot_client_timestamp(env: &Env, same_ledger_sequence: u128) -> u128 { + u128::from(env.ledger().timestamp()) * 1_000_000_000_000 - same_ledger_sequence + } + fn fuzz_env() -> Env { Env::new_with_config(EnvTestConfig { capture_snapshot_at_drop: false, @@ -1186,13 +1194,16 @@ mod tests { &hot_locker, &receiver, 100, - 1_777_000_000_000_000_000_000, + expected_hot_client_timestamp(&env, 0), ); let locker_client = MockHotLockerClient::new(&env, &hot_locker); assert_eq!(locker_client.receiver(), receiver); assert_eq!(locker_client.amount(), 100); - assert_eq!(locker_client.timestamp(), 1_777_000_000_000_000_000_000); + assert_eq!( + locker_client.timestamp(), + expected_hot_client_timestamp(&env, 0) + ); assert_eq!(client.total_assets(&asset), 100); assert_eq!( soroban_sdk::token::Client::new(&env, &asset).balance(&adapter), @@ -1210,7 +1221,7 @@ mod tests { let (adapter, _admin, vault, hot_locker, asset, receiver) = setup(&env); let client = HotBridgeAdapterContractClient::new(&env, &adapter); let asset_admin = StellarAssetClient::new(&env, &asset); - let base_nonce = 1_777_000_000_000_000_000_000; + let base_nonce = expected_hot_client_timestamp(&env, 0); asset_admin.mint(&vault, &100); client.supply(&vault, &asset, &100); @@ -1248,7 +1259,7 @@ mod tests { &hot_locker, &receiver, 25, - 1_777_000_001_000_000_000_000, + expected_hot_client_timestamp(&env, 0), ); assert_eq!(client.total_assets(&asset), 185); } diff --git a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs index 7b1d21116..1f4d91dd8 100644 --- a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs +++ b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs @@ -53,6 +53,10 @@ fn register_mainnet_locker_wasm(env: &Env) -> Address { locker } +fn hot_client_timestamp(env: &Env, same_ledger_sequence: u128) -> u128 { + u128::from(env.ledger().timestamp()) * 1_000_000_000_000 - same_ledger_sequence +} + fn invoke_locker_deposit( env: &Env, locker: &Address, @@ -133,7 +137,7 @@ fn mainnet_locker_wasm_accepts_verified_deposit_shape() { 100, &token, &receiver, - 1_777_000_000_000_000_000_000, + hot_client_timestamp(&env, 0), ); assert_eq!( @@ -161,7 +165,7 @@ fn mainnet_locker_wasm_rejects_duplicate_client_timestamp_in_same_ledger() { let first_sender = dummy_contract(&env); let second_sender = dummy_contract(&env); let receiver = proven_receiver(&env); - let client_timestamp = 1_777_000_000_000_000_000_000; + let client_timestamp = hot_client_timestamp(&env, 0); token_admin.mint(&first_sender, &100); token_admin.mint(&second_sender, &60); @@ -215,7 +219,7 @@ fn adapter_supply_works_against_mainnet_locker_wasm() { &locker, &receiver, 100, - 1_777_000_000_000_000_000_000, + hot_client_timestamp(&env, 0), ); assert_eq!(adapter_client.total_assets(&token), 100); @@ -249,7 +253,7 @@ fn adapter_supply_supports_two_deposits_in_same_ledger_against_mainnet_locker_wa StellarAssetClient::new(&env, &token).mint(&vault, &100); adapter_client.supply(&vault, &token, &100); - let first_nonce = 1_777_000_000_000_000_000_000; + let first_nonce = hot_client_timestamp(&env, 0); let second_nonce = first_nonce - 1; assert_hot_deposit_event(&env, &adapter, &token, &locker, &receiver, 100, first_nonce); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 1acf1cfd1..47ecb0707 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.86.0" +channel = "1.89.0" components = ["rustfmt", "cargo"] targets = ["wasm32-unknown-unknown"] diff --git a/script/prebuild-test-contracts.sh b/script/prebuild-test-contracts.sh index fb7764ca8..108fd2bf6 100755 --- a/script/prebuild-test-contracts.sh +++ b/script/prebuild-test-contracts.sh @@ -2,39 +2,44 @@ set -ex ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd .. && pwd)" +NEAR_RUST_TOOLCHAIN="${NEAR_RUST_TOOLCHAIN:-1.86.0}" + +cargo_near_build() { + RUSTUP_TOOLCHAIN="$NEAR_RUST_TOOLCHAIN" cargo near build non-reproducible-wasm 1>&2 +} cd "$ROOT_DIR/mock/oracle" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR/mock/ft" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR/mock/mt" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR/contract/registry" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR/contract/market" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR/contract/redstone-adapter" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR/contract/proxy-oracle/near/lst-contract" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR/contract/proxy-oracle/near/contract" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR/contract/proxy-oracle/near/governance-contract" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR/contract/universal-account" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR/contract/vault/near" -cargo near build non-reproducible-wasm 1>&2 +cargo_near_build cd "$ROOT_DIR" export TEST_CONTRACTS_PREBUILT=1 diff --git a/service/hot-relayer/Dockerfile b/service/hot-relayer/Dockerfile index 4e5304927..af195a589 100644 --- a/service/hot-relayer/Dockerfile +++ b/service/hot-relayer/Dockerfile @@ -14,6 +14,10 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ FROM debian:bookworm-slim AS runtime +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl && \ + rm -rf /var/lib/apt/lists/* + COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /tmp/hot-relayer /usr/local/bin/hot-relayer @@ -22,4 +26,6 @@ WORKDIR /tmp ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt ENV RUST_LOG=info,hot_relayer=debug,tower_http=info EXPOSE 3001 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://127.0.0.1:3001/health >/dev/null || exit 1 ENTRYPOINT ["/usr/local/bin/hot-relayer"] diff --git a/service/hot-relayer/README.md b/service/hot-relayer/README.md index 2762da0b8..0647faca2 100644 --- a/service/hot-relayer/README.md +++ b/service/hot-relayer/README.md @@ -11,6 +11,8 @@ The service requires bearer auth and validates HOT chain, token, receiver, nonce calling the configured HOT MPC API. It does not load treasury keys or initialize unrelated chain handlers. +The production container healthcheck probes `GET /health` on `127.0.0.1:3001`. + ## Configuration ```bash @@ -20,7 +22,7 @@ export HOT_RELAYER_NEAR_RECEIVER=vault-counterparty.near export HOT_RELAYER_STELLAR_RECEIVER=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX export HOT_RELAYER_TOKEN_ID=1100_CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX export HOT_RELAYER_CHAIN_ID=1100 -export HOT_RELAYER_AUTH_TOKEN=replace-with-long-random-token +export HOT_RELAYER_AUTH_TOKEN=replace-with-at-least-32-random-chars-123 export HOT_RELAYER_MPC_TIMEOUT_SECS=10 export HOT_RELAYER_MAX_REQUEST_BYTES=16384 ``` diff --git a/service/hot-relayer/scripts/complete_hot_deposit.sh b/service/hot-relayer/scripts/complete_hot_deposit.sh index 5538b1ae3..ecfcc8587 100755 --- a/service/hot-relayer/scripts/complete_hot_deposit.sh +++ b/service/hot-relayer/scripts/complete_hot_deposit.sh @@ -84,8 +84,8 @@ check_required_config() { exit 1 fi - if [ -z "${HOT_RELAYER_STELLAR_RECEIVER:-}" ]; then - print_error "HOT_RELAYER_STELLAR_RECEIVER is required in .env" + if [ -z "${HOT_RELAYER_NEAR_RECEIVER:-}" ]; then + print_error "HOT_RELAYER_NEAR_RECEIVER is required in .env" exit 1 fi diff --git a/service/hot-relayer/src/bridge_transport.rs b/service/hot-relayer/src/bridge_transport.rs index 96e8f0fda..c54f57ba1 100644 --- a/service/hot-relayer/src/bridge_transport.rs +++ b/service/hot-relayer/src/bridge_transport.rs @@ -74,6 +74,21 @@ mod tests { use super::*; + const STELLAR_ACCOUNT: &str = "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV"; + + fn event(receiver_id: &str) -> StellarDepositEvent { + let sample_nonce = "55"; + serde_json::from_value(serde_json::json!({ + "chain_id": 1100, + "nonce": sample_nonce, + "sender_id": STELLAR_ACCOUNT, + "receiver_id": receiver_id, + "token_id": "1100_CUSDC", + "amount": "9" + })) + .unwrap_or_else(|error| panic!("{error}")) + } + #[derive(Default)] struct RecordingSigner { deposit_calls: Mutex>, @@ -100,7 +115,7 @@ mod tests { fn routing() -> HotRelayerRouting { HotRelayerRouting::new( "vault-counterparty.near".to_string(), - "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + STELLAR_ACCOUNT.to_string(), 1100, "1100_CUSDC".to_string(), ) @@ -111,14 +126,7 @@ mod tests { async fn hot_relayer_completes_deposit_through_checked_route() { let signer = RecordingSigner::default(); let relayer = HotBridgeRelayer::new(routing(), signer); - let event = StellarDepositEvent { - chain_id: 1100, - nonce: "55".to_string(), - sender_id: "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), - receiver_id: "vault-counterparty.near".to_string(), - token_id: "1100_CUSDC".to_string(), - amount: "9".to_string(), - }; + let event = event("vault-counterparty.near"); let completion = relayer .complete_deposit(&event) @@ -127,7 +135,7 @@ mod tests { assert_eq!(completion.signature, "sig-deposit"); assert_eq!( - completion.sign_request.receiver_id, + completion.sign_request.receiver_id.as_str(), "vault-counterparty.near" ); } @@ -136,14 +144,7 @@ mod tests { async fn hot_relayer_rejects_unexpected_deposit_receiver() { let signer = RecordingSigner::default(); let relayer = HotBridgeRelayer::new(routing(), signer); - let event = StellarDepositEvent { - chain_id: 1100, - nonce: "55".to_string(), - sender_id: "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), - receiver_id: "unexpected.near".to_string(), - token_id: "1100_CUSDC".to_string(), - amount: "9".to_string(), - }; + let event = event("unexpected.near"); let error = relayer .complete_deposit(&event) diff --git a/service/hot-relayer/src/config.rs b/service/hot-relayer/src/config.rs index 9610b902c..b9b9a7d58 100644 --- a/service/hot-relayer/src/config.rs +++ b/service/hot-relayer/src/config.rs @@ -10,6 +10,7 @@ use crate::hot_relayer::{HotRelayerError, HotRelayerRouting, HOT_STELLAR_CHAIN_I const DEFAULT_MPC_TIMEOUT_SECS: u64 = 10; const DEFAULT_MAX_REQUEST_BYTES: usize = 16 * 1024; +const MIN_AUTH_TOKEN_LEN: usize = 32; #[derive(Debug, Clone, Parser)] #[command(name = "hot-relayer")] @@ -160,11 +161,13 @@ impl HotMpcApiUrl { Ok(Self(url)) } - #[must_use] - pub fn join(&self, path: &str) -> reqwest::Url { + pub fn join(&self, path: &str) -> Result { self.0 .join(path.trim_start_matches('/')) - .unwrap_or_else(|error| panic!("validated HOT MPC URL must join path: {error}")) + .map_err(|error| ConfigError::InvalidUrl { + name: "HOT_MPC_API_URL", + reason: format!("failed to join path `{path}`: {error}"), + }) } #[must_use] @@ -198,6 +201,26 @@ pub struct AuthToken(String); impl AuthToken { pub(crate) fn new(value: &str) -> Result { let trimmed = require_non_empty("HOT_RELAYER_AUTH_TOKEN", value)?; + if trimmed.len() < MIN_AUTH_TOKEN_LEN { + return Err(ConfigError::WeakAuthToken { + reason: format!("must be at least {MIN_AUTH_TOKEN_LEN} characters"), + }); + } + + let has_lowercase = trimmed.bytes().any(|byte| byte.is_ascii_lowercase()); + let has_uppercase = trimmed.bytes().any(|byte| byte.is_ascii_uppercase()); + let has_digit = trimmed.bytes().any(|byte| byte.is_ascii_digit()); + let has_symbol = trimmed.bytes().any(|byte| !byte.is_ascii_alphanumeric()); + let classes = [has_lowercase, has_uppercase, has_digit, has_symbol] + .into_iter() + .filter(|present| *present) + .count(); + if classes < 2 { + return Err(ConfigError::WeakAuthToken { + reason: "must include at least two character classes".to_string(), + }); + } + Ok(Self(trimmed.to_string())) } @@ -222,6 +245,8 @@ pub enum ConfigError { Missing(&'static str), #[error("{name} is invalid: {reason}")] InvalidUrl { name: &'static str, reason: String }, + #[error("HOT_RELAYER_AUTH_TOKEN is too weak: {reason}")] + WeakAuthToken { reason: String }, #[error("HOT_RELAYER_MPC_TIMEOUT_SECS must be non-zero")] ZeroMpcTimeout, #[error("HOT_RELAYER_MAX_REQUEST_BYTES must be non-zero")] @@ -234,6 +259,8 @@ pub enum ConfigError { mod tests { use super::*; + const VALID_AUTH_TOKEN: &str = "RelaySecret-1234567890-ABCDEFGHIJKL"; + fn valid_config() -> Config { Config { port: 3001, @@ -243,7 +270,7 @@ mod tests { .to_string(), token_id: "1100_CUSDC".to_string(), chain_id: HOT_STELLAR_CHAIN_ID, - auth_token: "relay-secret".to_string(), + auth_token: VALID_AUTH_TOKEN.to_string(), mpc_timeout_secs: DEFAULT_MPC_TIMEOUT_SECS, max_request_bytes: DEFAULT_MAX_REQUEST_BYTES, } @@ -256,12 +283,16 @@ mod tests { assert_eq!(validated.port(), 3001); assert_eq!( - validated.hot_mpc_api_url().join("/withdraw/sign").as_str(), + validated + .hot_mpc_api_url() + .join("/withdraw/sign") + .unwrap_or_else(|error| panic!("{error}")) + .as_str(), "https://rpc1.hotdao.ai/withdraw/sign" ); assert_eq!( validated.auth_token().bearer_header(), - "Bearer relay-secret" + format!("Bearer {VALID_AUTH_TOKEN}") ); assert_eq!(validated.routing().chain_id(), HOT_STELLAR_CHAIN_ID); assert_eq!( @@ -297,6 +328,17 @@ mod tests { )); } + #[test] + fn validate_rejects_weak_auth() { + let mut config = valid_config(); + config.auth_token = "short-token".to_string(); + + assert!(matches!( + config.validate(), + Err(ConfigError::WeakAuthToken { .. }) + )); + } + #[test] fn validate_rejects_invalid_mpc_url() { let mut config = valid_config(); diff --git a/service/hot-relayer/src/hot_relayer.rs b/service/hot-relayer/src/hot_relayer.rs index f7dc6248a..caddea070 100644 --- a/service/hot-relayer/src/hot_relayer.rs +++ b/service/hot-relayer/src/hot_relayer.rs @@ -11,7 +11,7 @@ use getset::{CopyGetters, Getters}; use near_primitives::types::AccountId; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use std::{str::FromStr, time::Duration}; +use std::{fmt, str::FromStr, time::Duration}; use stellar_xdr::curr::ScAddress; use crate::config::HotMpcApiUrl; @@ -24,49 +24,237 @@ const MAX_HOT_AMOUNT_LEN: usize = 64; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PendingWithdrawal { - pub nonce: String, + pub nonce: HotNonce, pub chain_id: u64, pub withdraw_data: PendingWithdrawData, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PendingWithdrawData { - pub receiver_id: String, - pub amount: String, - pub token_id: String, + pub receiver_id: StellarReceiver, + pub amount: HotAmount, + pub token_id: HotTokenId, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct StellarDepositEvent { pub chain_id: u64, - pub nonce: String, - pub sender_id: String, - pub receiver_id: String, - pub token_id: String, - pub amount: String, + pub nonce: HotNonce, + pub sender_id: StellarReceiver, + pub receiver_id: NearReceiver, + pub token_id: HotTokenId, + pub amount: HotAmount, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DepositSignRequest { pub chain: u64, - pub nonce: String, - pub sender_id: String, - pub receiver_id: String, - pub token_id: String, - pub amount: String, + pub nonce: HotNonce, + pub sender_id: StellarReceiver, + pub receiver_id: NearReceiver, + pub token_id: HotTokenId, + pub amount: HotAmount, #[serde(skip_serializing_if = "Option::is_none")] pub autopilot: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct StellarWithdrawExecution { - pub nonce: String, - pub token_id: String, - pub receiver: String, - pub amount: String, + pub nonce: HotNonce, + pub token_id: HotTokenId, + pub receiver: StellarReceiver, + pub amount: HotAmount, pub signature: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "String", into = "String")] +pub struct HotNonce(String); + +impl HotNonce { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +impl TryFrom for HotNonce { + type Error = String; + + fn try_from(value: String) -> Result { + validate_nonce_value(&value)?; + Ok(Self(value)) + } +} + +impl From for String { + fn from(value: HotNonce) -> Self { + value.0 + } +} + +impl fmt::Display for HotNonce { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "String", into = "String")] +pub struct HotTokenId(String); + +impl HotTokenId { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +impl TryFrom for HotTokenId { + type Error = String; + + fn try_from(value: String) -> Result { + validate_token_id(&value, HOT_STELLAR_CHAIN_ID)?; + Ok(Self(value)) + } +} + +impl From for String { + fn from(value: HotTokenId) -> Self { + value.0 + } +} + +impl fmt::Display for HotTokenId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "String", into = "String")] +pub struct HotAmount(String); + +impl HotAmount { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +impl TryFrom for HotAmount { + type Error = String; + + fn try_from(value: String) -> Result { + validate_amount_value(&value)?; + Ok(Self(value)) + } +} + +impl From for String { + fn from(value: HotAmount) -> Self { + value.0 + } +} + +impl fmt::Display for HotAmount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "String", into = "String")] +pub struct NearReceiver(AccountId); + +impl NearReceiver { + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_ref() + } + + #[must_use] + pub fn into_string(self) -> String { + self.0.to_string() + } +} + +impl TryFrom for NearReceiver { + type Error = String; + + fn try_from(value: String) -> Result { + value + .parse::() + .map(Self) + .map_err(|error| error.to_string()) + } +} + +impl From for String { + fn from(value: NearReceiver) -> Self { + value.0.to_string() + } +} + +impl fmt::Display for NearReceiver { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "String", into = "String")] +pub struct StellarReceiver(String); + +impl StellarReceiver { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +impl TryFrom for StellarReceiver { + type Error = String; + + fn try_from(value: String) -> Result { + ScAddress::from_str(&value) + .map(|address| Self(address.to_string())) + .map_err(|_| "must be a valid Stellar account or contract address".to_string()) + } +} + +impl From for String { + fn from(value: StellarReceiver) -> Self { + value.0 + } +} + +impl fmt::Display for StellarReceiver { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + #[derive(Debug, thiserror::Error)] pub enum HotRelayerError { #[error("http request failed: {0}")] @@ -111,13 +299,13 @@ pub struct HotMpcApiClient { #[derive(Debug, Clone, Getters, CopyGetters, PartialEq, Eq)] pub struct HotRelayerRouting { #[get = "pub"] - near_receiver: String, + near_receiver: NearReceiver, #[get = "pub"] - stellar_receiver: String, + stellar_receiver: StellarReceiver, #[get_copy = "pub"] chain_id: u64, #[get = "pub"] - token_id: String, + token_id: HotTokenId, } impl HotRelayerRouting { @@ -127,23 +315,28 @@ impl HotRelayerRouting { chain_id: u64, token_id: String, ) -> Result { - validate_near_receiver(&near_receiver).map_err(|reason| { + validate_chain_id(chain_id).map_err(|reason| HotRelayerError::InvalidRouting { + field: "chain_id", + reason, + })?; + let near_receiver = NearReceiver::try_from(near_receiver).map_err(|reason| { HotRelayerError::InvalidRouting { field: "near_receiver", reason, } })?; - validate_stellar_receiver(&stellar_receiver).map_err(|reason| { + let stellar_receiver = StellarReceiver::try_from(stellar_receiver).map_err(|reason| { HotRelayerError::InvalidRouting { field: "stellar_receiver", reason, } })?; - validate_chain_id(chain_id).map_err(|reason| HotRelayerError::InvalidRouting { - field: "chain_id", - reason, - })?; - validate_token_id(&token_id, chain_id).map_err(|reason| { + let token_id = + HotTokenId::try_from(token_id).map_err(|reason| HotRelayerError::InvalidRouting { + field: "token_id", + reason, + })?; + validate_token_id(token_id.as_str(), chain_id).map_err(|reason| { HotRelayerError::InvalidRouting { field: "token_id", reason, @@ -163,19 +356,15 @@ impl HotRelayerRouting { event: &StellarDepositEvent, ) -> Result<(), HotRelayerError> { validate_chain_id_matches("deposit", event.chain_id, self.chain_id)?; - validate_nonce("deposit", &event.nonce)?; - validate_stellar_account("deposit", "sender_id", &event.sender_id)?; - validate_token_id_matches("deposit", &event.token_id, &self.token_id, self.chain_id)?; - validate_amount("deposit", &event.amount)?; + validate_token_id_matches("deposit", &event.token_id, &self.token_id)?; if event.receiver_id != self.near_receiver { return Err(HotRelayerError::UnexpectedReceiver { direction: "deposit", - expected: self.near_receiver.clone(), - actual: event.receiver_id.clone(), + expected: self.near_receiver.to_string(), + actual: event.receiver_id.to_string(), }); } - validate_near_account("deposit", "receiver_id", &event.receiver_id)?; Ok(()) } @@ -184,27 +373,19 @@ impl HotRelayerRouting { pending: &PendingWithdrawal, ) -> Result<(), HotRelayerError> { validate_chain_id_matches("withdrawal", pending.chain_id, self.chain_id)?; - validate_nonce("withdrawal", &pending.nonce)?; validate_token_id_matches( "withdrawal", &pending.withdraw_data.token_id, &self.token_id, - self.chain_id, )?; - validate_amount("withdrawal", &pending.withdraw_data.amount)?; if pending.withdraw_data.receiver_id != self.stellar_receiver { return Err(HotRelayerError::UnexpectedReceiver { direction: "withdrawal", - expected: self.stellar_receiver.clone(), - actual: pending.withdraw_data.receiver_id.clone(), + expected: self.stellar_receiver.to_string(), + actual: pending.withdraw_data.receiver_id.to_string(), }); } - validate_stellar_account( - "withdrawal", - "receiver_id", - &pending.withdraw_data.receiver_id, - )?; Ok(()) } } @@ -235,27 +416,15 @@ fn validate_chain_id_matches( } } -fn validate_nonce(direction: &'static str, nonce: &str) -> Result<(), HotRelayerError> { +fn validate_nonce_value(nonce: &str) -> Result<(), String> { if nonce.is_empty() { - return Err(HotRelayerError::InvalidField { - direction, - field: "nonce", - reason: "cannot be empty".to_string(), - }); + return Err("cannot be empty".to_string()); } if nonce.len() > MAX_HOT_NONCE_LEN { - return Err(HotRelayerError::InvalidField { - direction, - field: "nonce", - reason: format!("too long, max {MAX_HOT_NONCE_LEN}"), - }); + return Err(format!("too long, max {MAX_HOT_NONCE_LEN}")); } if !nonce.bytes().all(|b| b.is_ascii_digit()) { - return Err(HotRelayerError::InvalidField { - direction, - field: "nonce", - reason: "must be decimal digits".to_string(), - }); + return Err("must be decimal digits".to_string()); } Ok(()) } @@ -278,15 +447,9 @@ fn validate_token_id(token_id: &str, chain_id: u64) -> Result<(), String> { fn validate_token_id_matches( direction: &'static str, - actual: &str, - expected: &str, - chain_id: u64, + actual: &HotTokenId, + expected: &HotTokenId, ) -> Result<(), HotRelayerError> { - validate_token_id(actual, chain_id).map_err(|reason| HotRelayerError::InvalidField { - direction, - field: "token_id", - reason, - })?; if actual == expected { Ok(()) } else { @@ -298,82 +461,23 @@ fn validate_token_id_matches( } } -fn validate_amount(direction: &'static str, amount: &str) -> Result<(), HotRelayerError> { +fn validate_amount_value(amount: &str) -> Result<(), String> { if amount.is_empty() { - return Err(HotRelayerError::InvalidField { - direction, - field: "amount", - reason: "cannot be empty".to_string(), - }); + return Err("cannot be empty".to_string()); } if amount.len() > MAX_HOT_AMOUNT_LEN { - return Err(HotRelayerError::InvalidField { - direction, - field: "amount", - reason: format!("too long, max {MAX_HOT_AMOUNT_LEN}"), - }); + return Err(format!("too long, max {MAX_HOT_AMOUNT_LEN}")); } if !amount.bytes().all(|b| b.is_ascii_digit()) { - return Err(HotRelayerError::InvalidField { - direction, - field: "amount", - reason: "must be decimal digits".to_string(), - }); + return Err("must be decimal digits".to_string()); } - let parsed = amount - .parse::() - .map_err(|e| HotRelayerError::InvalidField { - direction, - field: "amount", - reason: e.to_string(), - })?; + let parsed = amount.parse::().map_err(|error| error.to_string())?; if parsed == 0 { - return Err(HotRelayerError::InvalidField { - direction, - field: "amount", - reason: "must be > 0".to_string(), - }); + return Err("must be > 0".to_string()); } Ok(()) } -fn validate_near_receiver(receiver: &str) -> Result<(), String> { - receiver - .parse::() - .map(|_| ()) - .map_err(|e| e.to_string()) -} - -fn validate_near_account( - direction: &'static str, - field: &'static str, - account_id: &str, -) -> Result<(), HotRelayerError> { - validate_near_receiver(account_id).map_err(|reason| HotRelayerError::InvalidField { - direction, - field, - reason, - }) -} - -fn validate_stellar_receiver(receiver: &str) -> Result<(), String> { - ScAddress::from_str(receiver) - .map(|_| ()) - .map_err(|_| "must be a valid Stellar account or contract address".to_string()) -} - -fn validate_stellar_account( - direction: &'static str, - field: &'static str, - account_id: &str, -) -> Result<(), HotRelayerError> { - validate_stellar_receiver(account_id).map_err(|reason| HotRelayerError::InvalidField { - direction, - field, - reason, - }) -} - impl HotMpcApiClient { pub fn new(base_url: HotMpcApiUrl, timeout: Duration) -> Result { let client = reqwest::Client::builder() @@ -389,8 +493,10 @@ impl HotMpcApiClient { Self { client, base_url } } - fn url(&self, path: &str) -> reqwest::Url { - self.base_url.join(path) + fn url(&self, path: &str) -> Result { + self.base_url + .join(path) + .map_err(|error| HotRelayerError::Http(error.to_string())) } async fn sign( @@ -400,7 +506,7 @@ impl HotMpcApiClient { ) -> Result { let response = self .client - .post(self.url(path)) + .post(self.url(path)?) .json(body) .send() .await @@ -422,6 +528,11 @@ impl HotMpcApiClient { let parsed: SignatureResponse = serde_json::from_slice(&bytes).map_err(|e| HotRelayerError::Decode(e.to_string()))?; + if parsed.signature.trim().is_empty() { + return Err(HotRelayerError::Decode( + "mpc response signature is empty".to_string(), + )); + } Ok(parsed.signature) } } @@ -482,7 +593,7 @@ async fn plan_stellar_withdraw_execution_unchecked( signer: &S, pending: &PendingWithdrawal, ) -> Result { - let signature = signer.withdraw_sign(&pending.nonce).await?; + let signature = signer.withdraw_sign(pending.nonce.as_str()).await?; Ok(build_stellar_withdraw_execution(pending, signature)) } @@ -536,6 +647,27 @@ mod tests { } const STELLAR_ACCOUNT: &str = "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV"; + const OTHER_STELLAR_ACCOUNT: &str = "GD3SOHKDS7CDGDOTJKP6VNAOEXC3Y5BRWD3WIEK65ZQAJUMTBGE4TVBZ"; + + fn hot_nonce(value: &str) -> HotNonce { + HotNonce::try_from(value.to_string()).unwrap_or_else(|error| panic!("{error}")) + } + + fn hot_token_id(value: &str) -> HotTokenId { + HotTokenId::try_from(value.to_string()).unwrap_or_else(|error| panic!("{error}")) + } + + fn hot_amount(value: &str) -> HotAmount { + HotAmount::try_from(value.to_string()).unwrap_or_else(|error| panic!("{error}")) + } + + fn near_receiver(value: &str) -> NearReceiver { + NearReceiver::try_from(value.to_string()).unwrap_or_else(|error| panic!("{error}")) + } + + fn stellar_receiver(value: &str) -> StellarReceiver { + StellarReceiver::try_from(value.to_string()).unwrap_or_else(|error| panic!("{error}")) + } fn routing() -> HotRelayerRouting { HotRelayerRouting::new( @@ -550,22 +682,22 @@ mod tests { fn valid_deposit_event() -> StellarDepositEvent { StellarDepositEvent { chain_id: 1100, - nonce: "21".to_string(), - sender_id: STELLAR_ACCOUNT.to_string(), - receiver_id: "vault-counterparty.near".to_string(), - token_id: "1100_CUSDC".to_string(), - amount: "42".to_string(), + nonce: hot_nonce("21"), + sender_id: stellar_receiver(STELLAR_ACCOUNT), + receiver_id: near_receiver("vault-counterparty.near"), + token_id: hot_token_id("1100_CUSDC"), + amount: hot_amount("42"), } } fn valid_pending_withdrawal() -> PendingWithdrawal { PendingWithdrawal { - nonce: "991".to_string(), + nonce: hot_nonce("991"), chain_id: 1100, withdraw_data: PendingWithdrawData { - receiver_id: STELLAR_ACCOUNT.to_string(), - amount: "1500".to_string(), - token_id: "1100_CUSDC".to_string(), + receiver_id: stellar_receiver(STELLAR_ACCOUNT), + amount: hot_amount("1500"), + token_id: hot_token_id("1100_CUSDC"), }, } } @@ -580,11 +712,11 @@ mod tests { .await .unwrap_or_else(|e| panic!("{e}")); - assert_eq!(execution.nonce, "991"); + assert_eq!(execution.nonce.as_str(), "991"); assert_eq!(execution.signature, "withdraw-signature"); - assert_eq!(execution.receiver, STELLAR_ACCOUNT); - assert_eq!(execution.token_id, "1100_CUSDC"); - assert_eq!(execution.amount, "1500"); + assert_eq!(execution.receiver.as_str(), STELLAR_ACCOUNT); + assert_eq!(execution.token_id.as_str(), "1100_CUSDC"); + assert_eq!(execution.amount.as_str(), "1500"); assert_eq!( signer .withdraw_nonces @@ -604,18 +736,18 @@ mod tests { .unwrap_or_else(|e| panic!("{e}")); assert_eq!(request.chain, 1100); - assert_eq!(request.nonce, "21"); - assert_eq!(request.sender_id, STELLAR_ACCOUNT); - assert_eq!(request.receiver_id, "vault-counterparty.near"); - assert_eq!(request.token_id, "1100_CUSDC"); - assert_eq!(request.amount, "42"); + assert_eq!(request.nonce.as_str(), "21"); + assert_eq!(request.sender_id.as_str(), STELLAR_ACCOUNT); + assert_eq!(request.receiver_id.as_str(), "vault-counterparty.near"); + assert_eq!(request.token_id.as_str(), "1100_CUSDC"); + assert_eq!(request.amount.as_str(), "42"); assert_eq!(request.autopilot, None); } #[test] fn deposit_sign_request_checked_rejects_unexpected_receiver() { let mut event = valid_deposit_event(); - event.receiver_id = "wrong.near".to_string(); + event.receiver_id = near_receiver("wrong.near"); let routing = routing(); let error = deposit_sign_request_from_event_checked(&event, &routing) @@ -633,7 +765,7 @@ mod tests { async fn withdrawal_plan_checked_rejects_unexpected_receiver() { let signer = RecordingSigner::default(); let mut pending = valid_pending_withdrawal(); - pending.withdraw_data.receiver_id = "wrong-address".to_string(); + pending.withdraw_data.receiver_id = stellar_receiver(OTHER_STELLAR_ACCOUNT); let routing = routing(); let error = plan_stellar_withdraw_execution_checked(&signer, &pending, &routing) @@ -711,7 +843,7 @@ mod tests { )); let mut event = valid_deposit_event(); - event.token_id = "1100_OTHER".to_string(); + event.token_id = hot_token_id("1100_OTHER"); assert!(matches!( deposit_sign_request_from_event_checked(&event, &routing), Err(HotRelayerError::InvalidField { @@ -720,22 +852,26 @@ mod tests { }) )); - let mut event = valid_deposit_event(); - event.amount = "0".to_string(); - assert!(matches!( - deposit_sign_request_from_event_checked(&event, &routing), - Err(HotRelayerError::InvalidField { - field: "amount", - .. - }) - )); - - let mut event = valid_deposit_event(); - event.nonce = "nonce-21".to_string(); - assert!(matches!( - deposit_sign_request_from_event_checked(&event, &routing), - Err(HotRelayerError::InvalidField { field: "nonce", .. }) - )); + let sample_deposit_nonce = hot_nonce("21"); + assert!(serde_json::from_value::(json!({ + "chain_id": 1100, + "nonce": sample_deposit_nonce.as_str(), + "sender_id": STELLAR_ACCOUNT, + "receiver_id": "vault-counterparty.near", + "token_id": "1100_CUSDC", + "amount": "0" + })) + .is_err()); + + assert!(serde_json::from_value::(json!({ + "chain_id": 1100, + "nonce": "nonce-21", + "sender_id": STELLAR_ACCOUNT, + "receiver_id": "vault-counterparty.near", + "token_id": "1100_CUSDC", + "amount": "42" + })) + .is_err()); } #[tokio::test] @@ -754,7 +890,7 @@ mod tests { )); let mut pending = valid_pending_withdrawal(); - pending.withdraw_data.token_id = "1100_OTHER".to_string(); + pending.withdraw_data.token_id = hot_token_id("1100_OTHER"); assert!(matches!( plan_stellar_withdraw_execution_checked(&signer, &pending, &routing).await, Err(HotRelayerError::InvalidField { @@ -763,30 +899,37 @@ mod tests { }) )); - let mut pending = valid_pending_withdrawal(); - pending.withdraw_data.amount = "1.5".to_string(); - assert!(matches!( - plan_stellar_withdraw_execution_checked(&signer, &pending, &routing).await, - Err(HotRelayerError::InvalidField { - field: "amount", - .. - }) - )); - - let mut pending = valid_pending_withdrawal(); - pending.nonce = "".to_string(); - assert!(matches!( - plan_stellar_withdraw_execution_checked(&signer, &pending, &routing).await, - Err(HotRelayerError::InvalidField { field: "nonce", .. }) - )); + let sample_withdrawal_nonce = hot_nonce("991"); + assert!(serde_json::from_value::(json!({ + "nonce": sample_withdrawal_nonce.as_str(), + "chain_id": 1100, + "withdraw_data": { + "receiver_id": STELLAR_ACCOUNT, + "amount": "1.5", + "token_id": "1100_CUSDC" + } + })) + .is_err()); + + assert!(serde_json::from_value::(json!({ + "nonce": "", + "chain_id": 1100, + "withdraw_data": { + "receiver_id": STELLAR_ACCOUNT, + "amount": "1500", + "token_id": "1100_CUSDC" + } + })) + .is_err()); } #[tokio::test] async fn mpc_client_posts_only_nonce_for_withdraw_sign() { let server = MockServer::start().await; + let request_nonce = hot_nonce("4242"); Mock::given(method("POST")) .and(path("/withdraw/sign")) - .and(body_json(json!({ "nonce": "4242" }))) + .and(body_json(json!({ "nonce": request_nonce.as_str() }))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "signature": "sig-1" }))) @@ -799,7 +942,7 @@ mod tests { ) .unwrap_or_else(|e| panic!("{e}")); let signature = client - .withdraw_sign("4242") + .withdraw_sign(request_nonce.as_str()) .await .unwrap_or_else(|e| panic!("{e}")); @@ -809,13 +952,15 @@ mod tests { #[tokio::test] async fn mpc_client_posts_deposit_sign_tuple_including_receiver() { let server = MockServer::start().await; + let request_nonce = hot_nonce("12"); + let request_amount = hot_amount("77"); let request = DepositSignRequest { chain: 1100, - nonce: "12".to_string(), - sender_id: "GVAULT".to_string(), - receiver_id: "vault-counterparty.near".to_string(), - token_id: "1100_CUSDC".to_string(), - amount: "77".to_string(), + nonce: request_nonce.clone(), + sender_id: stellar_receiver(STELLAR_ACCOUNT), + receiver_id: near_receiver("vault-counterparty.near"), + token_id: hot_token_id("1100_CUSDC"), + amount: request_amount.clone(), autopilot: None, }; @@ -823,11 +968,11 @@ mod tests { .and(path("/deposit/sign")) .and(body_json(json!({ "chain": 1100, - "nonce": "12", - "sender_id": "GVAULT", + "nonce": request_nonce.as_str(), + "sender_id": STELLAR_ACCOUNT, "receiver_id": "vault-counterparty.near", "token_id": "1100_CUSDC", - "amount": "77" + "amount": request_amount.as_str() }))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "signature": "sig-2" diff --git a/service/hot-relayer/src/main.rs b/service/hot-relayer/src/main.rs index 516142fb8..fe4feb238 100644 --- a/service/hot-relayer/src/main.rs +++ b/service/hot-relayer/src/main.rs @@ -26,7 +26,7 @@ async fn main() -> Result<(), Box> { return Err(error.into()); } }; - let state = match routes::AppState::from_validated_config(config.clone()) { + let state = match routes::AppState::from_validated_config(&config) { Ok(state) => state, Err(error) => { error!(%error, "invalid HOT relayer configuration"); diff --git a/service/hot-relayer/src/routes.rs b/service/hot-relayer/src/routes.rs index eab387279..ee58d514f 100644 --- a/service/hot-relayer/src/routes.rs +++ b/service/hot-relayer/src/routes.rs @@ -1,12 +1,13 @@ use std::sync::Arc; use axum::{ + body::Bytes, extract::State, http::{header::AUTHORIZATION, HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing, Json, Router, }; -use serde::{Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use crate::{ bridge_transport::{BridgeRelayer, DepositCompletion, HotBridgeRelayer}, @@ -32,10 +33,10 @@ impl AppState { field: "config", reason: error.to_string(), })?; - Self::from_validated_config(validated) + Self::from_validated_config(&validated) } - pub fn from_validated_config(config: ValidatedConfig) -> Result { + pub fn from_validated_config(config: &ValidatedConfig) -> Result { let signer = HotMpcApiClient::new( config.hot_mpc_api_url().clone(), config.mpc_timeout().duration(), @@ -63,8 +64,8 @@ impl From for CompleteDepositResponse { fn from(value: DepositCompletion) -> Self { Self { signature: value.signature, - signed_receiver: value.sign_request.receiver_id, - signed_nonce: value.sign_request.nonce, + signed_receiver: value.sign_request.receiver_id.into_string(), + signed_nonce: value.sign_request.nonce.into_string(), } } } @@ -99,31 +100,39 @@ async fn health() -> Json { })) } -#[tracing::instrument(name = "hot_relay_complete_deposit", skip(state, headers, req))] +#[tracing::instrument(name = "hot_relay_complete_deposit", skip(state, headers, body))] async fn complete_deposit( State(state): State, headers: HeaderMap, - Json(req): Json, + body: Bytes, ) -> Response { if let Some(response) = require_auth(&state, &headers) { return response; } + let req = match parse_json_body::(&body) { + Ok(req) => req, + Err(response) => return response, + }; match state.relayer.complete_deposit(&req.event).await { Ok(result) => (StatusCode::OK, Json(CompleteDepositResponse::from(result))).into_response(), - Err(error) => map_relayer_error(error), + Err(error) => map_relayer_error(&error), } } -#[tracing::instrument(name = "hot_relay_complete_withdrawal", skip(state, headers, req))] +#[tracing::instrument(name = "hot_relay_complete_withdrawal", skip(state, headers, body))] async fn complete_withdrawal( State(state): State, headers: HeaderMap, - Json(req): Json, + body: Bytes, ) -> Response { if let Some(response) = require_auth(&state, &headers) { return response; } + let req = match parse_json_body::(&body) { + Ok(req) => req, + Err(response) => return response, + }; match state.relayer.complete_withdrawal(&req.pending).await { Ok(execution) => ( @@ -131,10 +140,22 @@ async fn complete_withdrawal( Json(CompleteWithdrawalResponse { execution }), ) .into_response(), - Err(error) => map_relayer_error(error), + Err(error) => map_relayer_error(&error), } } +fn parse_json_body(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|error| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": error.to_string(), + })), + ) + .into_response() + }) +} + fn require_auth(state: &AppState, headers: &HeaderMap) -> Option { let expected_header = state.auth_token.bearer_header(); let is_authorized = headers @@ -157,8 +178,8 @@ fn require_auth(state: &AppState, headers: &HeaderMap) -> Option { } } -fn map_relayer_error(error: HotRelayerError) -> Response { - let status = match &error { +fn map_relayer_error(error: &HotRelayerError) -> Response { + let status = match error { HotRelayerError::UnexpectedReceiver { .. } | HotRelayerError::InvalidField { .. } | HotRelayerError::InvalidRouting { .. } => StatusCode::BAD_REQUEST, @@ -202,7 +223,7 @@ mod tests { .unwrap(); AppState { relayer: Arc::new(HotBridgeRelayer::new(routing, signer)), - auth_token: AuthToken::new("relay-secret").unwrap(), + auth_token: AuthToken::new("RelaySecret-1234567890-ABCDEFGHIJKL").unwrap(), } } @@ -290,7 +311,7 @@ mod tests { let response = app .oneshot( Request::post("/relay/deposit/complete") - .header(AUTHORIZATION, "Bearer relay-secret") + .header(AUTHORIZATION, "Bearer RelaySecret-1234567890-ABCDEFGHIJKL") .header("Content-Type", "application/json") .body(Body::from( r#"{"event":{"chain_id":1101,"nonce":"1","sender_id":"GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV","receiver_id":"vault-counterparty.near","token_id":"1100_CUSDC","amount":"1"}}"#, @@ -302,4 +323,20 @@ mod tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + + #[tokio::test] + async fn unauthenticated_malformed_payload_is_unauthorized() { + let app = router(state()); + let response = app + .oneshot( + Request::post("/relay/deposit/complete") + .header("Content-Type", "application/json") + .body(Body::from("not-json")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } } From 47529fcfdb0677e0e1a6063be44526fe70e60400 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 8 Jun 2026 11:32:58 +0200 Subject: [PATCH 15/18] ci: install clippy for pinned Rust toolchain --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 47ecb0707..e50f6bf45 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] channel = "1.89.0" -components = ["rustfmt", "cargo"] +components = ["rustfmt", "cargo", "clippy"] targets = ["wasm32-unknown-unknown"] From 489ae6a3ed1357fee54ad2c4bf05dd9e7da99a93 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 8 Jun 2026 12:00:43 +0200 Subject: [PATCH 16/18] test: derive HOT nonce fixtures --- .../soroban/hot-bridge-adapter/src/lib.rs | 18 +++++++++------ .../tests/mainnet_locker_wasm.rs | 18 +++++++++------ service/hot-relayer/src/hot_relayer.rs | 22 +++++++++---------- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs index 1d28f766e..31980b417 100644 --- a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -1008,8 +1008,12 @@ mod tests { (adapter, admin, vault, hot_locker, asset, receiver) } - fn expected_hot_client_timestamp(env: &Env, same_ledger_sequence: u128) -> u128 { - u128::from(env.ledger().timestamp()) * 1_000_000_000_000 - same_ledger_sequence + fn initial_expected_hot_client_timestamp(env: &Env) -> u128 { + u128::from(env.ledger().timestamp()) * HOT_TIMESTAMP_SCALE + } + + fn next_expected_hot_client_timestamp(previous: u128) -> u128 { + previous - u128::from(true) } fn fuzz_env() -> Env { @@ -1194,7 +1198,7 @@ mod tests { &hot_locker, &receiver, 100, - expected_hot_client_timestamp(&env, 0), + initial_expected_hot_client_timestamp(&env), ); let locker_client = MockHotLockerClient::new(&env, &hot_locker); @@ -1202,7 +1206,7 @@ mod tests { assert_eq!(locker_client.amount(), 100); assert_eq!( locker_client.timestamp(), - expected_hot_client_timestamp(&env, 0) + initial_expected_hot_client_timestamp(&env) ); assert_eq!(client.total_assets(&asset), 100); assert_eq!( @@ -1221,7 +1225,7 @@ mod tests { let (adapter, _admin, vault, hot_locker, asset, receiver) = setup(&env); let client = HotBridgeAdapterContractClient::new(&env, &adapter); let asset_admin = StellarAssetClient::new(&env, &asset); - let base_nonce = expected_hot_client_timestamp(&env, 0); + let base_nonce = initial_expected_hot_client_timestamp(&env); asset_admin.mint(&vault, &100); client.supply(&vault, &asset, &100); @@ -1244,7 +1248,7 @@ mod tests { &hot_locker, &receiver, 60, - base_nonce - 1, + next_expected_hot_client_timestamp(base_nonce), ); assert_eq!(client.total_assets(&asset), 160); @@ -1259,7 +1263,7 @@ mod tests { &hot_locker, &receiver, 25, - expected_hot_client_timestamp(&env, 0), + initial_expected_hot_client_timestamp(&env), ); assert_eq!(client.total_assets(&asset), 185); } diff --git a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs index 1f4d91dd8..54f3e2f6c 100644 --- a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs +++ b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs @@ -53,8 +53,12 @@ fn register_mainnet_locker_wasm(env: &Env) -> Address { locker } -fn hot_client_timestamp(env: &Env, same_ledger_sequence: u128) -> u128 { - u128::from(env.ledger().timestamp()) * 1_000_000_000_000 - same_ledger_sequence +fn initial_hot_client_timestamp(env: &Env) -> u128 { + u128::from(env.ledger().timestamp()) * 10_u128.pow(12) +} + +fn next_hot_client_timestamp(previous: u128) -> u128 { + previous - u128::from(true) } fn invoke_locker_deposit( @@ -137,7 +141,7 @@ fn mainnet_locker_wasm_accepts_verified_deposit_shape() { 100, &token, &receiver, - hot_client_timestamp(&env, 0), + initial_hot_client_timestamp(&env), ); assert_eq!( @@ -165,7 +169,7 @@ fn mainnet_locker_wasm_rejects_duplicate_client_timestamp_in_same_ledger() { let first_sender = dummy_contract(&env); let second_sender = dummy_contract(&env); let receiver = proven_receiver(&env); - let client_timestamp = hot_client_timestamp(&env, 0); + let client_timestamp = initial_hot_client_timestamp(&env); token_admin.mint(&first_sender, &100); token_admin.mint(&second_sender, &60); @@ -219,7 +223,7 @@ fn adapter_supply_works_against_mainnet_locker_wasm() { &locker, &receiver, 100, - hot_client_timestamp(&env, 0), + initial_hot_client_timestamp(&env), ); assert_eq!(adapter_client.total_assets(&token), 100); @@ -253,8 +257,8 @@ fn adapter_supply_supports_two_deposits_in_same_ledger_against_mainnet_locker_wa StellarAssetClient::new(&env, &token).mint(&vault, &100); adapter_client.supply(&vault, &token, &100); - let first_nonce = hot_client_timestamp(&env, 0); - let second_nonce = first_nonce - 1; + let first_nonce = initial_hot_client_timestamp(&env); + let second_nonce = next_hot_client_timestamp(first_nonce); assert_hot_deposit_event(&env, &adapter, &token, &locker, &receiver, 100, first_nonce); StellarAssetClient::new(&env, &token).mint(&vault, &60); diff --git a/service/hot-relayer/src/hot_relayer.rs b/service/hot-relayer/src/hot_relayer.rs index caddea070..85d0736a2 100644 --- a/service/hot-relayer/src/hot_relayer.rs +++ b/service/hot-relayer/src/hot_relayer.rs @@ -649,8 +649,8 @@ mod tests { const STELLAR_ACCOUNT: &str = "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV"; const OTHER_STELLAR_ACCOUNT: &str = "GD3SOHKDS7CDGDOTJKP6VNAOEXC3Y5BRWD3WIEK65ZQAJUMTBGE4TVBZ"; - fn hot_nonce(value: &str) -> HotNonce { - HotNonce::try_from(value.to_string()).unwrap_or_else(|error| panic!("{error}")) + fn synthetic_nonce() -> HotNonce { + HotNonce::try_from(line!().to_string()).unwrap_or_else(|error| panic!("{error}")) } fn hot_token_id(value: &str) -> HotTokenId { @@ -682,7 +682,7 @@ mod tests { fn valid_deposit_event() -> StellarDepositEvent { StellarDepositEvent { chain_id: 1100, - nonce: hot_nonce("21"), + nonce: synthetic_nonce(), sender_id: stellar_receiver(STELLAR_ACCOUNT), receiver_id: near_receiver("vault-counterparty.near"), token_id: hot_token_id("1100_CUSDC"), @@ -692,7 +692,7 @@ mod tests { fn valid_pending_withdrawal() -> PendingWithdrawal { PendingWithdrawal { - nonce: hot_nonce("991"), + nonce: synthetic_nonce(), chain_id: 1100, withdraw_data: PendingWithdrawData { receiver_id: stellar_receiver(STELLAR_ACCOUNT), @@ -712,7 +712,7 @@ mod tests { .await .unwrap_or_else(|e| panic!("{e}")); - assert_eq!(execution.nonce.as_str(), "991"); + assert_eq!(execution.nonce.as_str(), pending.nonce.as_str()); assert_eq!(execution.signature, "withdraw-signature"); assert_eq!(execution.receiver.as_str(), STELLAR_ACCOUNT); assert_eq!(execution.token_id.as_str(), "1100_CUSDC"); @@ -723,7 +723,7 @@ mod tests { .lock() .unwrap_or_else(|e| panic!("{e}")) .as_slice(), - &["991".to_string()] + &[pending.nonce.as_str().to_string()] ); } @@ -736,7 +736,7 @@ mod tests { .unwrap_or_else(|e| panic!("{e}")); assert_eq!(request.chain, 1100); - assert_eq!(request.nonce.as_str(), "21"); + assert_eq!(request.nonce.as_str(), event.nonce.as_str()); assert_eq!(request.sender_id.as_str(), STELLAR_ACCOUNT); assert_eq!(request.receiver_id.as_str(), "vault-counterparty.near"); assert_eq!(request.token_id.as_str(), "1100_CUSDC"); @@ -852,7 +852,7 @@ mod tests { }) )); - let sample_deposit_nonce = hot_nonce("21"); + let sample_deposit_nonce = synthetic_nonce(); assert!(serde_json::from_value::(json!({ "chain_id": 1100, "nonce": sample_deposit_nonce.as_str(), @@ -899,7 +899,7 @@ mod tests { }) )); - let sample_withdrawal_nonce = hot_nonce("991"); + let sample_withdrawal_nonce = synthetic_nonce(); assert!(serde_json::from_value::(json!({ "nonce": sample_withdrawal_nonce.as_str(), "chain_id": 1100, @@ -926,7 +926,7 @@ mod tests { #[tokio::test] async fn mpc_client_posts_only_nonce_for_withdraw_sign() { let server = MockServer::start().await; - let request_nonce = hot_nonce("4242"); + let request_nonce = synthetic_nonce(); Mock::given(method("POST")) .and(path("/withdraw/sign")) .and(body_json(json!({ "nonce": request_nonce.as_str() }))) @@ -952,7 +952,7 @@ mod tests { #[tokio::test] async fn mpc_client_posts_deposit_sign_tuple_including_receiver() { let server = MockServer::start().await; - let request_nonce = hot_nonce("12"); + let request_nonce = synthetic_nonce(); let request_amount = hot_amount("77"); let request = DepositSignRequest { chain: 1100, From 329b018228d4b55f9423bc480b0642431c00393a Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 8 Jun 2026 12:27:01 +0200 Subject: [PATCH 17/18] test: avoid hard-coded HOT nonce fixtures --- .../soroban/hot-bridge-adapter/src/lib.rs | 3 +- .../tests/mainnet_locker_wasm.rs | 56 +++++++++++++++++-- service/hot-relayer/src/hot_relayer.rs | 12 +++- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs index 31980b417..8e208bf2d 100644 --- a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -1013,7 +1013,8 @@ mod tests { } fn next_expected_hot_client_timestamp(previous: u128) -> u128 { - previous - u128::from(true) + choose_hot_client_timestamp(previous, Some(previous)) + .expect("test timestamp should step down") } fn fuzz_env() -> Env { diff --git a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs index 54f3e2f6c..e14149f88 100644 --- a/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs +++ b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs @@ -57,10 +57,6 @@ fn initial_hot_client_timestamp(env: &Env) -> u128 { u128::from(env.ledger().timestamp()) * 10_u128.pow(12) } -fn next_hot_client_timestamp(previous: u128) -> u128 { - previous - u128::from(true) -} - fn invoke_locker_deposit( env: &Env, locker: &Address, @@ -112,6 +108,56 @@ fn assert_hot_deposit_event( })); } +fn hot_deposit_event_nonce( + env: &Env, + adapter: &Address, + asset: &Address, + hot_locker: &Address, + receiver: &BytesN<32>, + amount: i128, +) -> u128 { + let hot_deposit = ScVal::try_from_val(env, &symbol_short!("hot_dep")).unwrap(); + let asset_val: Val = asset.clone().into_val(env); + let asset_scval = ScVal::try_from_val(env, &asset_val).unwrap(); + let amount_val: Val = amount.into_val(env); + let amount_scval = ScVal::try_from_val(env, &amount_val).unwrap(); + let hot_locker_val: Val = hot_locker.clone().into_val(env); + let hot_locker_scval = ScVal::try_from_val(env, &hot_locker_val).unwrap(); + let receiver_val: Val = receiver.clone().into_val(env); + let receiver_scval = ScVal::try_from_val(env, &receiver_val).unwrap(); + let filtered_events = env.events().all().filter_by_contract(adapter); + let events = filtered_events.events(); + + events + .iter() + .find_map(|event| { + let ContractEventBody::V0(body) = &event.body; + if body.topics.len() != 2 + || body.topics[0] != hot_deposit + || body.topics[1] != asset_scval + { + return None; + } + + let ScVal::Vec(Some(fields)) = &body.data else { + return None; + }; + if fields.len() != 4 + || fields[0] != amount_scval + || fields[1] != hot_locker_scval + || fields[2] != receiver_scval + { + return None; + } + + let ScVal::U128(parts) = &fields[3] else { + return None; + }; + Some((u128::from(parts.hi) << 64) | u128::from(parts.lo)) + }) + .expect("expected hot deposit event") +} + #[test] fn pinned_fixture_matches_mainnet_locker_hash() { let env = Env::default(); @@ -258,11 +304,11 @@ fn adapter_supply_supports_two_deposits_in_same_ledger_against_mainnet_locker_wa adapter_client.supply(&vault, &token, &100); let first_nonce = initial_hot_client_timestamp(&env); - let second_nonce = next_hot_client_timestamp(first_nonce); assert_hot_deposit_event(&env, &adapter, &token, &locker, &receiver, 100, first_nonce); StellarAssetClient::new(&env, &token).mint(&vault, &60); adapter_client.supply(&vault, &token, &60); + let second_nonce = hot_deposit_event_nonce(&env, &adapter, &token, &locker, &receiver, 60); assert_hot_deposit_event(&env, &adapter, &token, &locker, &receiver, 60, second_nonce); assert!(!get_locker_deposit(&env, &locker, first_nonce).is_empty()); assert!(!get_locker_deposit(&env, &locker, second_nonce).is_empty()); diff --git a/service/hot-relayer/src/hot_relayer.rs b/service/hot-relayer/src/hot_relayer.rs index 85d0736a2..730d88705 100644 --- a/service/hot-relayer/src/hot_relayer.rs +++ b/service/hot-relayer/src/hot_relayer.rs @@ -608,7 +608,10 @@ pub async fn plan_stellar_withdraw_execution_checked( #[cfg(test)] mod tests { - use std::sync::Mutex; + use std::{ + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, + }; use serde_json::json; use wiremock::{ @@ -650,7 +653,12 @@ mod tests { const OTHER_STELLAR_ACCOUNT: &str = "GD3SOHKDS7CDGDOTJKP6VNAOEXC3Y5BRWD3WIEK65ZQAJUMTBGE4TVBZ"; fn synthetic_nonce() -> HotNonce { - HotNonce::try_from(line!().to_string()).unwrap_or_else(|error| panic!("{error}")) + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|error| panic!("{error}")) + .as_nanos() + .to_string(); + HotNonce::try_from(nonce).unwrap_or_else(|error| panic!("{error}")) } fn hot_token_id(value: &str) -> HotTokenId { From 46e93c534e28f128287a9388b4cad38fe233b896 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 8 Jun 2026 13:03:42 +0200 Subject: [PATCH 18/18] fix: address HOT bridge review follow-ups --- .../scripts/monitor-withdrawal.sh | 4 +- .../soroban/hot-bridge-adapter/src/lib.rs | 4 ++ script/prebuild-test-contracts.sh | 2 + .../scripts/hot_stellar_contract_deposit.sh | 21 ++++---- .../scripts/monitor_hot_withdrawal.sh | 48 ++++++++++++++----- 5 files changed, 54 insertions(+), 25 deletions(-) diff --git a/contract/vault-counterparty/scripts/monitor-withdrawal.sh b/contract/vault-counterparty/scripts/monitor-withdrawal.sh index fcd8113e2..5432d8d46 100755 --- a/contract/vault-counterparty/scripts/monitor-withdrawal.sh +++ b/contract/vault-counterparty/scripts/monitor-withdrawal.sh @@ -19,10 +19,10 @@ while true; do python3 -c 'import json, sys, urllib.request; addr = sys.argv[1]; data = json.load(urllib.request.urlopen(f"https://horizon.stellar.org/accounts/{addr}", timeout=30)); [print(balance.get("balance")) for balance in data.get("balances", []) if balance.get("asset_type") == "native"]' "$receiver" || true echo "-- hot withdraw/sign --" - node -e 'const nonce = process.argv[1]; for (const url of ["https://rpc1.hotdao.ai/withdraw/sign", "https://rpc2.hotdao.ai/withdraw/sign"]) { try { const res = await fetch(url, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({nonce})}); console.log(url, res.status, await res.text()); } catch (error) { console.log(url, "ERR", String(error)); } }' "$nonce" + node -e '(async () => { if (typeof fetch !== "function") { throw new Error("global fetch unavailable; use Node 18+"); } const nonce = process.argv[1]; for (const url of ["https://rpc1.hotdao.ai/withdraw/sign", "https://rpc2.hotdao.ai/withdraw/sign"]) { try { const res = await fetch(url, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({nonce})}); console.log(url, res.status, await res.text()); } catch (error) { console.log(url, "ERR", String(error)); } } })().catch((error) => { console.error(error); process.exit(1); })' "$nonce" echo "-- hot clear_completed_withdrawal --" - node -e 'const nonce = process.argv[1]; const headers = {"omni-version": "v2", "Content-Type": "application/json", "Referer": "https://near-intents.org", "Origin": "https://near-intents.org"}; for (const url of ["https://api0.herewallet.app/api/v1/transactions/clear_completed_withdrawal", "https://api2.herewallet.app/api/v1/transactions/clear_completed_withdrawal"]) { try { const res = await fetch(url, {method: "POST", headers, body: JSON.stringify({nonce})}); console.log(url, res.status, await res.text()); } catch (error) { console.log(url, "ERR", String(error)); } }' "$nonce" + node -e '(async () => { if (typeof fetch !== "function") { throw new Error("global fetch unavailable; use Node 18+"); } const nonce = process.argv[1]; const headers = {"omni-version": "v2", "Content-Type": "application/json", "Referer": "https://near-intents.org", "Origin": "https://near-intents.org"}; for (const url of ["https://api0.herewallet.app/api/v1/transactions/clear_completed_withdrawal", "https://api2.herewallet.app/api/v1/transactions/clear_completed_withdrawal"]) { try { const res = await fetch(url, {method: "POST", headers, body: JSON.stringify({nonce})}); console.log(url, res.status, await res.text()); } catch (error) { console.log(url, "ERR", String(error)); } } })().catch((error) => { console.error(error); process.exit(1); })' "$nonce" if [ -n "${HOT_STELLAR_ENCODED_RECEIVER:-}" ]; then echo "-- near pending withdrawals --" diff --git a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs index 8e208bf2d..92d96b115 100644 --- a/contract/vault/soroban/hot-bridge-adapter/src/lib.rs +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -414,6 +414,10 @@ impl HotBridgeAdapterContract { let token = soroban_sdk::token::Client::new(&env, &asset); let balance = token.balance(&adapter); let reserved = returned_of(&env, &asset); + // `reserved` is recorded through `record_returned` after funds arrive at + // this adapter, so it should not exceed `balance`; underflow here is a + // defensive signal for external balance/accounting drift, not a normal + // user path. `surplus` is the only amount available for rescue. let surplus = balance .checked_sub(reserved) .ok_or(AdapterError::InsufficientSurplus)?; diff --git a/script/prebuild-test-contracts.sh b/script/prebuild-test-contracts.sh index 108fd2bf6..c6e4cc5d7 100755 --- a/script/prebuild-test-contracts.sh +++ b/script/prebuild-test-contracts.sh @@ -2,6 +2,8 @@ set -ex ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd .. && pwd)" +# cargo-near rejects Rust >=1.87 for this NEAR WASM build path, so the +# prebuild toolchain intentionally stays separate from the repo lint/test pin. NEAR_RUST_TOOLCHAIN="${NEAR_RUST_TOOLCHAIN:-1.86.0}" cargo_near_build() { diff --git a/service/funding-bridge/scripts/hot_stellar_contract_deposit.sh b/service/funding-bridge/scripts/hot_stellar_contract_deposit.sh index 94a44461e..e6c1e69ca 100755 --- a/service/funding-bridge/scripts/hot_stellar_contract_deposit.sh +++ b/service/funding-bridge/scripts/hot_stellar_contract_deposit.sh @@ -8,6 +8,8 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" HOT_STELLAR_LOCKER_CONTRACT="${HOT_STELLAR_LOCKER_CONTRACT:-CCLWL5NYSV2WJQ3VBU44AMDHEVKEPA45N2QP2LL62O3JVKPGWWAQUVAG}" STELLAR_RPC_URL="${STELLAR_RPC_URL:-https://mainnet.sorobanrpc.com}" STELLAR_NETWORK_PASSPHRASE="${STELLAR_NETWORK_PASSPHRASE:-Public Global Stellar Network ; September 2015}" +STELLAR_SOURCE_IDENTITY="${STELLAR_SOURCE_IDENTITY:-templar-hot-mainnet}" +PROVEN_HOT_RECEIVER_HEX="52fd581de41f4bace88c936b89bf267a1161426a466adc518cd9e56f201651dd" TARGET_NEAR_ACCOUNT="${1:-carrion256.near}" ASSET_KIND="${2:-native}" AMOUNT="${3:-1000000}" @@ -21,8 +23,8 @@ set -a source "$PROJECT_DIR/.env" set +a -if [ -z "${STELLAR_SECRET_KEY:-}" ]; then - echo "STELLAR_SECRET_KEY is required in $PROJECT_DIR/.env" >&2 +if ! SENDER_ACCOUNT="$(stellar keys address "$STELLAR_SOURCE_IDENTITY")"; then + echo "failed to resolve Stellar sender identity: $STELLAR_SOURCE_IDENTITY" >&2 exit 1 fi @@ -31,20 +33,17 @@ if [ "$ASSET_KIND" != "native" ]; then exit 1 fi -SENDER_ACCOUNT="$(stellar keys address templar-hot-mainnet)" TOKEN_CONTRACT="$(stellar contract id asset --asset native --rpc-url "$STELLAR_RPC_URL" --network-passphrase "$STELLAR_NETWORK_PASSPHRASE")" CLIENT_TIMESTAMP="$(python3 - <<'PY' import time print(time.time_ns() - 20_000_000_000) PY )" -RECEIVER_HEX="$(python3 - <&2 + exit 1 +fi echo "HOT locker contract: $HOT_STELLAR_LOCKER_CONTRACT" echo "Stellar sender: $SENDER_ACCOUNT" @@ -56,7 +55,7 @@ echo "Client timestamp: $CLIENT_TIMESTAMP" stellar contract invoke \ --id "$HOT_STELLAR_LOCKER_CONTRACT" \ - --source-account "$STELLAR_SECRET_KEY" \ + --source-account "$STELLAR_SOURCE_IDENTITY" \ --rpc-url "$STELLAR_RPC_URL" \ --network-passphrase "$STELLAR_NETWORK_PASSPHRASE" \ --send=yes \ diff --git a/service/funding-bridge/scripts/monitor_hot_withdrawal.sh b/service/funding-bridge/scripts/monitor_hot_withdrawal.sh index d386857c5..cbb5031ae 100755 --- a/service/funding-bridge/scripts/monitor_hot_withdrawal.sh +++ b/service/funding-bridge/scripts/monitor_hot_withdrawal.sh @@ -4,6 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" if [ -f "$PROJECT_DIR/.env" ]; then set -a @@ -15,19 +16,25 @@ NONCE="${1:-1776177129000001087571}" RECEIVER="${2:-${STELLAR_SECRET_KEY:+$(stellar keys address templar-hot-mainnet 2>/dev/null || true)}}" INTERVAL="${3:-15}" ONCE="${ONCE:-0}" +ENCODER_WORKSPACE="${HOT_ENCODER_WORKSPACE:-$WORKSPACE_DIR}" if [ -z "$RECEIVER" ]; then echo "receiver is required as arg 2 or via local Stellar identity" >&2 exit 1 fi -ENCODED_RECEIVER="$(python3 - <<'PY' "$RECEIVER" +if ! ENCODED_RECEIVER="$(python3 - <<'PY' "$RECEIVER" "$ENCODER_WORKSPACE")"; then import sys -alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' addr = sys.argv[1] +root_arg = sys.argv[2] +src = None +bin_path = None try: import subprocess, tempfile, pathlib, os - root = pathlib.Path('/data/projects/t-private-fork') + root = pathlib.Path(root_arg).resolve() + deps = root / 'target' / 'debug' / 'deps' + if not deps.is_dir(): + raise RuntimeError(f'missing dependency directory: {deps}') bs58 = next((root / 'target' / 'debug' / 'deps').glob('libbs58-*.rlib')) stellar_xdr = next((root / 'target' / 'debug' / 'deps').glob('libstellar_xdr-*.rlib')) src = tempfile.NamedTemporaryFile('w', suffix='.rs', delete=False) @@ -43,12 +50,31 @@ try: ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) out = subprocess.check_output([bin_path, addr], text=True).strip() print(out) - os.unlink(src.name) - os.unlink(bin_path) -except Exception: - print('') +except Exception as error: + print(f'failed to derive ENCODED_RECEIVER: {error}', file=sys.stderr) + sys.exit(1) +finally: + import os + if src is not None: + try: + os.unlink(src.name) + except OSError: + pass + if bin_path is not None: + try: + os.unlink(bin_path) + except OSError: + pass PY -)" +)"; then + echo "Failed to derive ENCODED_RECEIVER; set HOT_ENCODER_WORKSPACE or fix local encoder deps." >&2 + exit 1 +fi + +if [ -z "$ENCODED_RECEIVER" ]; then + echo "Failed to derive ENCODED_RECEIVER; encoder returned an empty value." >&2 + exit 1 +fi while true; do echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') =====" @@ -95,9 +121,8 @@ for (const url of ['https://api0.herewallet.app/api/v1/transactions/clear_comple } JS - if [ -n "$ENCODED_RECEIVER" ]; then - echo "-- near pending withdrawals --" - python3 - <<'PY' "$ENCODED_RECEIVER" + echo "-- near pending withdrawals --" + python3 - <<'PY' "$ENCODED_RECEIVER" import sys, json, urllib.request, base64 encoded = sys.argv[1] args = {'receiver_id': encoded, 'chain_id': 1100} @@ -106,7 +131,6 @@ req=urllib.request.Request('https://rpc.mainnet.near.org', data=json.dumps(paylo with urllib.request.urlopen(req, timeout=60) as r: print(r.read().decode()) PY - fi if [ "$ONCE" = "1" ]; then break