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/Cargo.lock b/Cargo.lock index df92448da..527682bf1 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" @@ -13090,6 +13125,15 @@ dependencies = [ "templar-vault-kernel", ] +[[package]] +name = "templar-soroban-hot-bridge-adapter" +version = "0.1.0" +dependencies = [ + "proptest", + "soroban-sdk", + "stellar-contract-utils", +] + [[package]] name = "templar-soroban-runtime" version = "0.1.0" @@ -13237,6 +13281,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "templar-vault-counterparty-contract" +version = "0.1.0" +dependencies = [ + "bs58 0.5.1", + "near-sdk", + "proptest", + "stellar-xdr 24.0.1", +] + [[package]] name = "templar-vault-kernel" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 185fcb6d9..e1dc9068f 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", @@ -27,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", @@ -67,6 +69,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/.env.example b/contract/vault-counterparty/.env.example new file mode 100644 index 000000000..542ec7bd9 --- /dev/null +++ b/contract/vault-counterparty/.env.example @@ -0,0 +1,31 @@ +# 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 only for the synthetic funding path (`just fund` / `just smoke-synthetic`) +FUNDER_ACCOUNT=your-funder.near + +# 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) +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..f4d5b5e14 --- /dev/null +++ b/contract/vault-counterparty/Cargo.toml @@ -0,0 +1,21 @@ +[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 +bs58 = "0.5" +stellar-xdr = { version = "24.0", default-features = false, features = ["curr", "std"] } + +[dev-dependencies] +near-sdk = { workspace = true, features = ["unit-testing"] } +proptest = "1.4" + +[lints] +workspace = true diff --git a/contract/vault-counterparty/README.md b/contract/vault-counterparty/README.md new file mode 100644 index 000000000..a6e6daaa0 --- /dev/null +++ b/contract/vault-counterparty/README.md @@ -0,0 +1,94 @@ +# 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_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. +- `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 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). +- 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`) + - 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. + +## 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: + +- A real Soroban adapter address (for example from `upstream/feat/soroban-curator-kernel`) +- A mock placeholder receiver during local testing + +`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 + +```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 new file mode 100644 index 000000000..03a9a0c46 --- /dev/null +++ b/contract/vault-counterparty/justfile @@ -0,0 +1,226 @@ +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", "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 + +# 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 " HOT_STELLAR_RECEIVER_HEX"; + @echo " OMNI_TOKEN_ID"; + @echo " FUNDER_ACCOUNT (for fund/smoke)"; + @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: 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"; + @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-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 + 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}}"'","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: + : "${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" + @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}" + : "${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}}" + +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}" + : "${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" + +monitor-withdrawal nonce receiver='' interval='15': check-monitor-tools + 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 + : "${FUNDER_ACCOUNT:?set FUNDER_ACCOUNT}" + just bootstrap + just fund "{{fund_amount}}" + just balances + just forward "{{forward_amount}}" + just balances + 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' market_withdraw_amount='50' stellar_withdraw_amount='50': check-env + just bridge-preflight + just balances + just forward "{{forward_amount}}" + just balances + just request-market-withdraw "{{market_withdraw_amount}}" + just execute-market-withdraw + just balances + just withdraw "{{stellar_withdraw_amount}}" + just balances + +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-counterparty/scripts/monitor-withdrawal.sh b/contract/vault-counterparty/scripts/monitor-withdrawal.sh new file mode 100755 index 000000000..5432d8d46 --- /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 '(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 '(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 --" + 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 new file mode 100644 index 000000000..e663c848c --- /dev/null +++ b/contract/vault-counterparty/src/lib.rs @@ -0,0 +1,1794 @@ +#![allow(clippy::needless_pass_by_value)] + +use near_sdk::{ + env, + json_types::U128, + near, require, + serde_json::{self, json}, + AccountId, Gas, NearToken, PanicOnDefault, Promise, PromiseOrValue, PromiseResult, +}; +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 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 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\""; +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, + pub curator: AccountId, + pub owner: AccountId, + 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, +} + +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 canonical = sc_address.to_string(); + Self { raw: canonical } + } + + fn as_str(&self) -> &str { + &self.raw + } + + 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) + } +} + +#[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(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(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)] +#[allow(clippy::struct_field_names)] +pub struct Contract { + schema_version: u32, + stellar_receiver: StellarReceiver, + near_market: AccountId, + omni_token_id: OmniTokenId, + curator: AccountId, + owner: AccountId, + 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, +} + +#[near] +impl Contract { + #[init] + #[allow(clippy::too_many_arguments)] + pub fn new( + stellar_receiver: String, + 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 { + schema_version: STORAGE_VERSION, + stellar_receiver, + near_market, + omni_token_id, + curator, + owner, + 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(&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", + json!({ + "receiver_id": self.near_market, + "token_id": self.omni_token_id_for_contract(), + "amount": amount, + "msg": MARKET_SUPPLY_MSG, + }), + 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); + + self.call_market( + "create_supply_withdrawal_request", + json!({ + "amount": amount, + }), + GAS_MARKET_WITHDRAWAL_REQUEST, + ) + } + + pub fn cancel_market_withdrawal(&self) -> Promise { + self.assert_not_paused(); + 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_not_paused(); + self.assert_curator(); + + self.call_market( + "execute_next_supply_withdrawal_request", + json!({ + "batch_limit": batch_limit, + }), + 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()); + } + + #[allow(clippy::used_underscore_binding)] + 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(&mut self, amount: U128) -> Promise { + self.assert_not_paused(); + self.assert_curator(); + Self::assert_amount(amount); + 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), + ) + } + + pub fn mt_on_transfer( + &mut self, + sender_id: AccountId, + previous_owner_ids: Vec, + token_ids: Vec, + amounts: Vec, + msg: String, + ) -> PromiseOrValue> { + let _ = sender_id; + + require!( + env::predecessor_account_id() == self.intents_integration.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); + self.assert_not_paused(); + Self::assert_withdraw_gas_budget(); + + let supplier = previous_owner_ids[0].clone(); + self.increase_supply_position(supplier, amount.0); + 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, operation_id: u64) -> Promise { + 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(self.intents_integration.contract.clone()).function_call( + "mt_withdraw".to_string(), + serde_json::to_vec(&json!({ + "token": self.intents_token_contract(), + "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(operation_id), + })) + .unwrap_or_else(|_| env::panic_str("failed to serialize withdrawal args")), + ONE_YOCTO, + forwarded_gas, + ) + } + + 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() + } + + fn intents_token_contract(&self) -> String { + self.omni_contract.to_string() + } + + fn intents_multi_token_id(&self) -> String { + 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(), + serde_json::to_vec(&args) + .unwrap_or_else(|_| env::panic_str("failed to serialize omni call args")), + ONE_YOCTO, + gas, + ) + } + + 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(self.intents_integration.contract.clone()).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 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"); + 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(); + 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 account_id(value: &str) -> AccountId { + value + .parse() + .unwrap_or_else(|_| env::panic_str("invalid account id constant")) +} + +#[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 + .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"); + } + + #[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(), + curator: self.curator.clone(), + 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(), + 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(), + } + } + + 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_not_paused(&self) { + require!(!self.paused, "contract is paused"); + } + + fn assert_withdraw_gas_budget() { + 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(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(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)] +mod tests { + use near_sdk::{ + mock::MockAction, + serde_json::Value, + test_utils::{get_created_receipts, VMContextBuilder}, + test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, + }; + use proptest::prelude::*; + use std::collections::HashMap; + + use super::*; + + const HOT_DEPOSIT_RECEIVER_HEX: &str = + "52fd581de41f4bace88c936b89bf267a1161426a466adc518cd9e56f201651dd"; + + fn account(account_id: &str) -> AccountId { + account_id + .parse() + .unwrap_or_else(|_| panic!("invalid test account id")) + } + + 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(), + 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"), + ) + } + + fn first_function_call() -> (AccountId, String, Value, NearToken, Gas) { + let receipts = get_created_receipts(); + assert!( + !receipts.is_empty(), + "expected at least 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, + ) + } + + 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:{omni_contract}:{token_id}") + } 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_supply() { + let mut contract = test_contract(); + context(&account("curator.near")); + + 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_call"); + assert_eq!(attached_deposit, ONE_YOCTO); + 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] + fn wrapped_token_id_is_normalized_for_omni_calls() { + let mut 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"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), + account("curator.near"), + account("owner.near"), + ); + context(&account("curator.near")); + + let _ = contract.withdraw_to_stellar(U128(1)); + + let (_, _, args, _, _) = first_function_call(); + assert_eq!( + args["token_ids"][0], + "1100_111bzQBB5v7AhLyPMDwS8uJgQV24KaAPXtwyVWu2KXbbfQU6NXRCz" + ); + } + + #[test] + fn withdraw_to_stellar_uses_hardcoded_receiver_and_token() { + let mut contract = test_contract(); + context(&account("curator.near")); + + let _ = contract.withdraw_to_stellar(U128(999)); + + let (receiver, method, args, attached_deposit, prepaid_gas) = first_function_call(); + assert_eq!(receiver, account(INTENTS_CONTRACT)); + assert_eq!(method, "mt_withdraw"); + assert_eq!(attached_deposit, ONE_YOCTO); + 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!( + msg["receiver_id"], + Contract::encode_stellar_sc_address( + &ScAddress::from_str("GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV") + .unwrap_or_else(|_| panic!("invalid test stellar receiver")) + ) + ); + 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 mut contract = test_contract(); + context(&account("not-curator.near")); + + 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] + #[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_with_deposit(&account("owner.near"), ONE_YOCTO); + contract.set_curator(account("new-curator.near")); + + context(&account("new-curator.near")); + let _ = contract.withdraw_to_stellar(U128(5)); + + let (_, method, _, _, _) = first_function_call(); + 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 mut contract = test_contract(); + context(&account("curator.near")); + let _ = 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 mut contract = test_contract(); + context(&account("curator.near")); + 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] + #[should_panic(expected = "Only owner can call this method")] + fn non_owner_cannot_rotate_curator() { + let mut contract = test_contract(); + 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_with_deposit(&account("owner.near"), ONE_YOCTO); + contract.propose_owner(account("new-owner.near")); + + context_with_deposit(&account("new-owner.near"), ONE_YOCTO); + 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")); + assert_eq!(config.hot_deposit_receiver_hex, HOT_DEPOSIT_RECEIVER_HEX); + } + + #[test] + #[should_panic(expected = "Only pending owner can accept ownership")] + fn non_pending_owner_cannot_accept_ownership() { + let mut contract = test_contract(); + context_with_deposit(&account("owner.near"), ONE_YOCTO); + contract.propose_owner(account("new-owner.near")); + + context_with_deposit(&account("someone-else.near"), ONE_YOCTO); + contract.accept_owner(); + } + + #[test] + #[should_panic(expected = "token_id cannot be empty")] + fn init_rejects_empty_token_id() { + let _ = Contract::new( + "GCMVV45LOZUYYVXOQJ626VXGL3KFXY73DHFBT4EDPDBE2LN4USRQDYVV".to_string(), + account("templar-market.near"), + String::new(), + account("v2_1.omni.hot.tg"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), + account("curator.near"), + 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"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), + 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"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), + 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"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), + 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"), + HOT_DEPOSIT_RECEIVER_HEX.to_string(), + account("curator.near"), + account("owner.near"), + ); + + assert_eq!(contract.get_config().omni_token_id, "1100_stellar_usdc"); + } +} 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..e866ec72e 100644 --- a/contract/vault/soroban/README.md +++ b/contract/vault/soroban/README.md @@ -283,6 +283,102 @@ 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 +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` +- `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. + +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 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..9961f443e --- /dev/null +++ b/contract/vault/soroban/hot-bridge-adapter/Cargo.toml @@ -0,0 +1,26 @@ +[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] +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/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 new file mode 100644 index 000000000..92d96b115 --- /dev/null +++ b/contract/vault/soroban/hot-bridge-adapter/src/lib.rs @@ -0,0 +1,1446 @@ +#![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; +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, + PendingAdminTransfer, + Vault, + HotLocker, + HotReceiverId, + 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] +#[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, + HotClientTimestampExhausted = 11, + UnsupportedAsset = 12, + PendingReturnedBalance = 13, + InsufficientRecordedReturn = 14, + InsufficientSurplus = 15, + PendingAdminExpired = 16, +} + +#[contract] +pub struct HotBridgeAdapterContract; + +#[contractimpl] +#[allow(deprecated)] +impl HotBridgeAdapterContract { + pub fn __constructor( + env: Env, + 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); + Ok(()) + } + + pub fn set_paused(env: Env, caller: Address, paused: bool) -> Result<(), AdapterError> { + extend_instance_ttl(&env); + require_admin_or_vault(&env, &caller)?; + 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(()) + } + + 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)?; + 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); + } + + 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 = next_hot_locker_timestamp(&env)?; + let amount_u128 = amount_as_u128(amount)?; + let token = soroban_sdk::token::Client::new(&env, &asset); + 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); + + let args: Vec = ( + adapter.clone(), + amount_u128, + asset.clone(), + hot_receiver_id.clone(), + client_timestamp, + ) + .into_val(&env); + let returned_nonce = + env.invoke_contract::(&hot_locker, &Symbol::new(&env, "deposit"), args); + + let balance_after = token.balance(&adapter); + 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)?; + 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(()) + } + + 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)?; + require_configured_asset(&env, &asset)?; + + 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); + 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); + Ok(amount) + } + + pub fn total_assets(env: Env, asset: Address) -> Result { + extend_instance_ttl(&env); + require_configured_asset(&env, &asset)?; + Ok(principal_of(&env, &asset)) + } + + pub fn principal(env: Env, asset: Address) -> i128 { + extend_instance_ttl(&env); + 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) + } + + 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 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, + new_admin: Address, + ) -> Result<(), AdapterError> { + 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::PendingAdminTransfer, &transfer); + 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: PendingAdminTransfer = env + .storage() + .instance() + .get(&DataKey::PendingAdminTransfer) + .ok_or(AdapterError::MissingConfig)?; + 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::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::<_, 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( + 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_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(); + 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)?; + if amount > surplus { + return Err(AdapterError::InsufficientSurplus); + } + token.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 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); + 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 + .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 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, + 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); + } + + Ok(()) +} + +fn principal_of(env: &Env, asset: &Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Principal(asset.clone())) + .unwrap_or(0) +} + +fn principal_after_increase(current: i128, amount: i128) -> Result { + current + .checked_add(amount) + .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); + Ok(()) +} + +fn decrease_principal(env: &Env, asset: &Address, amount: i128) -> Result<(), AdapterError> { + let updated = principal_after_decrease(principal_of(env, asset), amount)?; + let key = DataKey::Principal(asset.clone()); + if updated == 0 { + env.storage().instance().remove(&key); + } else { + env.storage().instance().set(&key, &updated); + } + 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() + .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 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)? { + 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_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)?; + let vault = get_vault(env)?; + if caller != &admin && caller != &vault { + return Err(AdapterError::Unauthorized); + } + Ok(()) +} + +fn is_paused(env: &Env) -> bool { + matches!( + env.storage() + .instance() + .get(&DataKey::OperationalState) + .unwrap_or(OperationalState::Active), + OperationalState::Paused(_, _) + ) +} + +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); +} + +#[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 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 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 { + assert_eq!(result, Ok(())); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, EnvTestConfig, Events as _, Ledger as _}, + token::StellarAssetClient, + xdr::{ContractEventBody, ScVal}, + TryFromVal, + }; + + #[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, &asset, &receiver), + ); + (adapter, admin, vault, hot_locker, asset, receiver) + } + + 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 { + choose_hot_client_timestamp(previous, Some(previous)) + .expect("test timestamp should step down") + } + + fn fuzz_env() -> Env { + Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }) + } + + 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 + })); + } + + 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(&vault, &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::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; + } + } + } + + 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, &asset, &receiver), + ); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + let token = soroban_sdk::token::Client::new(&env, &asset); + 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), 0); + prop_assert_eq!(token.balance(&vault), amount); + prop_assert_eq!(token.balance(&hot_locker), 0); + } + } + + #[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(&vault, &100); + + client.supply(&vault, &asset, &100); + assert_hot_deposit_event( + &env, + &adapter, + &asset, + &hot_locker, + &receiver, + 100, + initial_expected_hot_client_timestamp(&env), + ); + + 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(), + initial_expected_hot_client_timestamp(&env) + ); + 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 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 = initial_expected_hot_client_timestamp(&env); + + asset_admin.mint(&vault, &100); + client.supply(&vault, &asset, &100); + assert_hot_deposit_event( + &env, + &adapter, + &asset, + &hot_locker, + &receiver, + 100, + base_nonce, + ); + + asset_admin.mint(&vault, &60); + client.supply(&vault, &asset, &60); + assert_hot_deposit_event( + &env, + &adapter, + &asset, + &hot_locker, + &receiver, + 60, + next_expected_hot_client_timestamp(base_nonce), + ); + assert_eq!(client.total_assets(&asset), 160); + + env.ledger().set_timestamp(1_777_000_001); + asset_admin.mint(&vault, &25); + client.supply(&vault, &asset, &25); + + assert_hot_deposit_event( + &env, + &adapter, + &asset, + &hot_locker, + &receiver, + 25, + initial_expected_hot_client_timestamp(&env), + ); + assert_eq!(client.total_assets(&asset), 185); + } + + #[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(&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); + 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(&vault, &100); + client.supply(&vault, &asset, &100); + + let error = client.try_progress_withdrawal(&vault, &asset, &1); + 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] + 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, &asset, &receiver), + ); + let client = HotBridgeAdapterContractClient::new(&env, &adapter); + 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] + 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/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 000000000..70623e417 Binary files /dev/null and b/contract/vault/soroban/hot-bridge-adapter/tests/fixtures/hot_stellar_locker_mainnet_cc74acb56fd01ef560b5d443cd58fe11d5acbece0e4001612ebb31020fc92f97.wasm differ 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 new file mode 100644 index 000000000..e14149f88 --- /dev/null +++ b/contract/vault/soroban/hot-bridge-adapter/tests/mainnet_locker_wasm.rs @@ -0,0 +1,320 @@ +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{Address as _, Events as _, Ledger as _}, + token::StellarAssetClient, + xdr::{ContractEventBody, ScVal}, + Address, Bytes, BytesN, Env, IntoVal, Symbol, TryFromVal, Val, Vec, +}; +use templar_soroban_hot_bridge_adapter::{ + HotBridgeAdapterContract, HotBridgeAdapterContractClient, +}; + +const HOT_LOCKER_WASM: &[u8] = include_bytes!( + "fixtures/hot_stellar_locker_mainnet_cc74acb56fd01ef560b5d443cd58fe11d5acbece0e4001612ebb31020fc92f97.wasm" +); +const HOT_LOCKER_SHA256: [u8; 32] = [ + 0xcc, 0x74, 0xac, 0xb5, 0x6f, 0xd0, 0x1e, 0xf5, 0x60, 0xb5, 0xd4, 0x43, 0xcd, 0x58, 0xfe, 0x11, + 0xd5, 0xac, 0xbe, 0xce, 0x0e, 0x40, 0x01, 0x61, 0x2e, 0xbb, 0x31, 0x02, 0x0f, 0xc9, 0x2f, 0x97, +]; +const HOT_PUBLIC_KEY: [u8; 65] = [ + 0x04, 0x4d, 0x4a, 0xad, 0x55, 0xb2, 0x48, 0x97, 0x2a, 0x44, 0xbc, 0x5d, 0x78, 0x1e, 0x2e, 0xa9, + 0x86, 0x46, 0x9d, 0xac, 0xbe, 0xb2, 0xf3, 0x43, 0xf3, 0xa9, 0xaa, 0xe7, 0x27, 0x0c, 0x75, 0x42, + 0xcb, 0xf7, 0xb0, 0x2a, 0x79, 0x49, 0xaf, 0x99, 0x8d, 0xe3, 0xaa, 0xb8, 0x90, 0xd5, 0xc9, 0xea, + 0xe2, 0x9e, 0x5e, 0xe5, 0x27, 0x73, 0x65, 0xf5, 0xaf, 0xe4, 0x9b, 0x40, 0x68, 0x25, 0x61, 0xf3, + 0xaf, +]; +const PROVEN_HOT_RECEIVER_ID: [u8; 32] = [ + 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, +]; + +#[contract] +struct DummyContract; + +#[contractimpl] +impl DummyContract {} + +fn dummy_contract(env: &Env) -> 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 initial_hot_client_timestamp(env: &Env) -> u128 { + u128::from(env.ledger().timestamp()) * 10_u128.pow(12) +} + +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) +} + +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 + })); +} + +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(); + 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, + initial_hot_client_timestamp(&env), + ); + + 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] +#[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 = initial_hot_client_timestamp(&env); + 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(); + 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, &token, &receiver), + ); + let adapter_client = HotBridgeAdapterContractClient::new(&env, &adapter); + StellarAssetClient::new(&env, &token).mint(&vault, &100); + + adapter_client.supply(&vault, &token, &100); + assert_hot_deposit_event( + &env, + &adapter, + &token, + &locker, + &receiver, + 100, + initial_hot_client_timestamp(&env), + ); + + 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 + ); +} + +#[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, &token, &receiver), + ); + let adapter_client = HotBridgeAdapterContractClient::new(&env, &adapter); + StellarAssetClient::new(&env, &token).mint(&vault, &100); + adapter_client.supply(&vault, &token, &100); + + let first_nonce = initial_hot_client_timestamp(&env); + 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()); + 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 7750e2f0c..0de06cff8 100644 --- a/contract/vault/soroban/justfile +++ b/contract/vault/soroban/justfile @@ -34,6 +34,12 @@ 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 +# 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) # # ============================================================================= @@ -48,6 +54,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" @@ -55,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" @@ -134,6 +143,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 +158,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 +554,120 @@ 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}"; \ + 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}}}"; \ + 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 " Asset token: $asset_token"; \ + echo " Locker hash: $actual_hash"; \ + 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" \ + --asset "$asset_token" \ + --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 +1288,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 +1317,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]" diff --git a/plans/CROSS_CHAIN_BRIDGE.md b/plans/CROSS_CHAIN_BRIDGE.md new file mode 100644 index 000000000..4f12c5753 --- /dev/null +++ b/plans/CROSS_CHAIN_BRIDGE.md @@ -0,0 +1,137 @@ +# 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. +- 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 + +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. +- 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 + +- 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` + +## 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 diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 1acf1cfd1..e50f6bf45 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.86.0" -components = ["rustfmt", "cargo"] +channel = "1.89.0" +components = ["rustfmt", "cargo", "clippy"] targets = ["wasm32-unknown-unknown"] diff --git a/script/prebuild-test-contracts.sh b/script/prebuild-test-contracts.sh index fb7764ca8..c6e4cc5d7 100755 --- a/script/prebuild-test-contracts.sh +++ b/script/prebuild-test-contracts.sh @@ -2,39 +2,46 @@ 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() { + 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/funding-bridge/README.md b/service/funding-bridge/README.md index 77c5ec3d5..770664307 100644 --- a/service/funding-bridge/README.md +++ b/service/funding-bridge/README.md @@ -272,6 +272,11 @@ curl "http://localhost:3000/tokens/lookup?asset=USDT&chain=ethereum" } ``` +#### 6. HOT Relay Completion + +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 ### 1. Liquidation Bot Pre-funding diff --git a/service/funding-bridge/scripts/hot_stellar_contract_deposit.sh b/service/funding-bridge/scripts/hot_stellar_contract_deposit.sh new file mode 100755 index 000000000..e6c1e69ca --- /dev/null +++ b/service/funding-bridge/scripts/hot_stellar_contract_deposit.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +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}" + +if [ ! -f "$PROJECT_DIR/.env" ]; then + echo "Missing $PROJECT_DIR/.env" >&2 + exit 1 +fi + +set -a +source "$PROJECT_DIR/.env" +set +a + +if ! SENDER_ACCOUNT="$(stellar keys address "$STELLAR_SOURCE_IDENTITY")"; then + echo "failed to resolve Stellar sender identity: $STELLAR_SOURCE_IDENTITY" >&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 + +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="${RECEIVER_HEX_OVERRIDE:-${HOT_RECEIVER_HEX:-$PROVEN_HOT_RECEIVER_HEX}}" +if [[ ! "$RECEIVER_HEX" =~ ^[0-9A-Fa-f]{64}$ ]]; then + echo "receiver hex must be exactly 64 hex characters" >&2 + exit 1 +fi + +echo "HOT locker contract: $HOT_STELLAR_LOCKER_CONTRACT" +echo "Stellar sender: $SENDER_ACCOUNT" +echo "NEAR receiver: $TARGET_NEAR_ACCOUNT" +echo "Receiver hash: $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 "$STELLAR_SOURCE_IDENTITY" \ + --rpc-url "$STELLAR_RPC_URL" \ + --network-passphrase "$STELLAR_NETWORK_PASSPHRASE" \ + --send=yes \ + -- \ + deposit \ + --sender_id "$SENDER_ACCOUNT" \ + --receiver_id "$RECEIVER_HEX" \ + --token "$TOKEN_CONTRACT" \ + --client_timestamp "$CLIENT_TIMESTAMP" \ + --amount "$AMOUNT" diff --git a/service/funding-bridge/scripts/monitor_hot_withdrawal.sh b/service/funding-bridge/scripts/monitor_hot_withdrawal.sh new file mode 100755 index 000000000..cbb5031ae --- /dev/null +++ b/service/funding-bridge/scripts/monitor_hot_withdrawal.sh @@ -0,0 +1,140 @@ +#!/bin/bash + +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 + source "$PROJECT_DIR/.env" + set +a +fi + +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 + +if ! ENCODED_RECEIVER="$(python3 - <<'PY' "$RECEIVER" "$ENCODER_WORKSPACE")"; then +import sys +addr = sys.argv[1] +root_arg = sys.argv[2] +src = None +bin_path = None +try: + import subprocess, tempfile, pathlib, os + 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) + 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) +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') =====" + 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 + + 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 + + if [ "$ONCE" = "1" ]; then + break + fi + + sleep "$INTERVAL" +done 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..af195a589 --- /dev/null +++ b/service/hot-relayer/Dockerfile @@ -0,0 +1,31 @@ +# 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 + +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 + +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 +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 new file mode 100644 index 000000000..0647faca2 --- /dev/null +++ b/service/hot-relayer/README.md @@ -0,0 +1,43 @@ +# 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. + +The production container healthcheck probes `GET /health` on `127.0.0.1:3001`. + +## 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-at-least-32-random-chars-123 +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/hot-relayer/scripts/complete_hot_deposit.sh b/service/hot-relayer/scripts/complete_hot_deposit.sh new file mode 100755 index 000000000..ecfcc8587 --- /dev/null +++ b/service/hot-relayer/scripts/complete_hot_deposit.sh @@ -0,0 +1,159 @@ +#!/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:3001 + HOT_RELAYER_AUTH_TOKEN Required bearer token + HOT_RELAYER_NEAR_RECEIVER Required when receiver_id is not passed + HOT_RELAYER_TOKEN_ID Expected HOT token id configured in the service + HOT_RELAYER_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=(-H "Authorization: Bearer $HOT_RELAYER_AUTH_TOKEN") +} + +check_required_config() { + if [ -z "${HOT_MPC_API_URL:-}" ]; then + print_error "HOT_MPC_API_URL is required in .env" + exit 1 + fi + + if [ -z "${HOT_RELAYER_NEAR_RECEIVER:-}" ]; then + print_error "HOT_RELAYER_NEAR_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:-}" +SENDER_ID="${2:-}" +TOKEN_ID="${3:-}" +AMOUNT="${4:-}" +RECEIVER_ID="${5:-${HOT_RELAYER_NEAR_RECEIVER:-}}" +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 + exit 1 +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" + exit 1 +fi + +check_required_config +build_auth_args +require_health + +JSON_PAYLOAD="$(python3 - < 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::*; + + 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>, + } + + #[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::new( + "vault-counterparty.near".to_string(), + STELLAR_ACCOUNT.to_string(), + 1100, + "1100_CUSDC".to_string(), + ) + .unwrap_or_else(|e| panic!("{e}")) + } + + #[tokio::test] + async fn hot_relayer_completes_deposit_through_checked_route() { + let signer = RecordingSigner::default(); + let relayer = HotBridgeRelayer::new(routing(), signer); + let event = event("vault-counterparty.near"); + + 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.as_str(), + "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 = event("unexpected.near"); + + let error = relayer + .complete_deposit(&event) + .await + .expect_err("expected receiver mismatch"); + assert!(matches!( + error, + HotRelayerError::UnexpectedReceiver { + direction: "deposit", + .. + } + )); + } +} diff --git a/service/hot-relayer/src/config.rs b/service/hot-relayer/src/config.rs new file mode 100644 index 000000000..b9b9a7d58 --- /dev/null +++ b/service/hot-relayer/src/config.rs @@ -0,0 +1,386 @@ +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; +const MIN_AUTH_TOKEN_LEN: usize = 32; + +#[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)) + } + + pub fn join(&self, path: &str) -> Result { + self.0 + .join(path.trim_start_matches('/')) + .map_err(|error| ConfigError::InvalidUrl { + name: "HOT_MPC_API_URL", + reason: format!("failed to join path `{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)?; + 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())) + } + + #[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_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")] + ZeroMaxRequestBytes, + #[error("{0}")] + Relayer(#[from] HotRelayerError), +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_AUTH_TOKEN: &str = "RelaySecret-1234567890-ABCDEFGHIJKL"; + + 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: VALID_AUTH_TOKEN.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") + .unwrap_or_else(|error| panic!("{error}")) + .as_str(), + "https://rpc1.hotdao.ai/withdraw/sign" + ); + assert_eq!( + validated.auth_token().bearer_header(), + format!("Bearer {VALID_AUTH_TOKEN}") + ); + 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_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(); + 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..730d88705 --- /dev/null +++ b/service/hot-relayer/src/hot_relayer.rs @@ -0,0 +1,1003 @@ +//! 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::{fmt, 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: HotNonce, + pub chain_id: u64, + pub withdraw_data: PendingWithdrawData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingWithdrawData { + 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: 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: 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: 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}")] + 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: NearReceiver, + #[get = "pub"] + stellar_receiver: StellarReceiver, + #[get_copy = "pub"] + chain_id: u64, + #[get = "pub"] + token_id: HotTokenId, +} + +impl HotRelayerRouting { + pub fn new( + near_receiver: String, + stellar_receiver: String, + chain_id: u64, + token_id: String, + ) -> Result { + 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, + } + })?; + let stellar_receiver = StellarReceiver::try_from(stellar_receiver).map_err(|reason| { + HotRelayerError::InvalidRouting { + field: "stellar_receiver", + 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, + } + })?; + + 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_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.to_string(), + actual: event.receiver_id.to_string(), + }); + } + Ok(()) + } + + pub fn validate_pending_withdrawal( + &self, + pending: &PendingWithdrawal, + ) -> Result<(), HotRelayerError> { + validate_chain_id_matches("withdrawal", pending.chain_id, self.chain_id)?; + validate_token_id_matches( + "withdrawal", + &pending.withdraw_data.token_id, + &self.token_id, + )?; + + if pending.withdraw_data.receiver_id != self.stellar_receiver { + return Err(HotRelayerError::UnexpectedReceiver { + direction: "withdrawal", + expected: self.stellar_receiver.to_string(), + actual: pending.withdraw_data.receiver_id.to_string(), + }); + } + 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_value(nonce: &str) -> Result<(), String> { + if nonce.is_empty() { + return Err("cannot be empty".to_string()); + } + if nonce.len() > 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("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: &HotTokenId, + expected: &HotTokenId, +) -> Result<(), HotRelayerError> { + if actual == expected { + Ok(()) + } else { + Err(HotRelayerError::InvalidField { + direction, + field: "token_id", + reason: format!("expected {expected}, got {actual}"), + }) + } +} + +fn validate_amount_value(amount: &str) -> Result<(), String> { + if amount.is_empty() { + return Err("cannot be empty".to_string()); + } + if amount.len() > 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("must be decimal digits".to_string()); + } + let parsed = amount.parse::().map_err(|error| error.to_string())?; + if parsed == 0 { + return Err("must be > 0".to_string()); + } + Ok(()) +} + +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) -> Result { + self.base_url + .join(path) + .map_err(|error| HotRelayerError::Http(error.to_string())) + } + + 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()))?; + if parsed.signature.trim().is_empty() { + return Err(HotRelayerError::Decode( + "mpc response signature is empty".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.as_str()).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, + time::{SystemTime, UNIX_EPOCH}, + }; + + 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"; + const OTHER_STELLAR_ACCOUNT: &str = "GD3SOHKDS7CDGDOTJKP6VNAOEXC3Y5BRWD3WIEK65ZQAJUMTBGE4TVBZ"; + + fn synthetic_nonce() -> HotNonce { + 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 { + 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( + "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: synthetic_nonce(), + 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: synthetic_nonce(), + chain_id: 1100, + withdraw_data: PendingWithdrawData { + receiver_id: stellar_receiver(STELLAR_ACCOUNT), + amount: hot_amount("1500"), + token_id: hot_token_id("1100_CUSDC"), + }, + } + } + + #[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.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"); + assert_eq!(execution.amount.as_str(), "1500"); + assert_eq!( + signer + .withdraw_nonces + .lock() + .unwrap_or_else(|e| panic!("{e}")) + .as_slice(), + &[pending.nonce.as_str().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.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"); + 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 = near_receiver("wrong.near"); + 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 = stellar_receiver(OTHER_STELLAR_ACCOUNT); + 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 = hot_token_id("1100_OTHER"); + assert!(matches!( + deposit_sign_request_from_event_checked(&event, &routing), + Err(HotRelayerError::InvalidField { + field: "token_id", + .. + }) + )); + + let sample_deposit_nonce = synthetic_nonce(); + 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] + 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 = hot_token_id("1100_OTHER"); + assert!(matches!( + plan_stellar_withdraw_execution_checked(&signer, &pending, &routing).await, + Err(HotRelayerError::InvalidField { + field: "token_id", + .. + }) + )); + + let sample_withdrawal_nonce = synthetic_nonce(); + 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 = synthetic_nonce(); + Mock::given(method("POST")) + .and(path("/withdraw/sign")) + .and(body_json(json!({ "nonce": request_nonce.as_str() }))) + .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(request_nonce.as_str()) + .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_nonce = synthetic_nonce(); + let request_amount = hot_amount("77"); + let request = DepositSignRequest { + chain: 1100, + 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, + }; + + Mock::given(method("POST")) + .and(path("/deposit/sign")) + .and(body_json(json!({ + "chain": 1100, + "nonce": request_nonce.as_str(), + "sender_id": STELLAR_ACCOUNT, + "receiver_id": "vault-counterparty.near", + "token_id": "1100_CUSDC", + "amount": request_amount.as_str() + }))) + .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..fe4feb238 --- /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) { + 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..ee58d514f --- /dev/null +++ b/service/hot-relayer/src/routes.rs @@ -0,0 +1,342 @@ +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::State, + http::{header::AUTHORIZATION, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing, Json, Router, +}; +use serde::{de::DeserializeOwned, 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.into_string(), + signed_nonce: value.sign_request.nonce.into_string(), + } + } +} + +#[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, body))] +async fn complete_deposit( + State(state): State, + headers: HeaderMap, + 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), + } +} + +#[tracing::instrument(name = "hot_relay_complete_withdrawal", skip(state, headers, body))] +async fn complete_withdrawal( + State(state): State, + headers: HeaderMap, + 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) => ( + StatusCode::OK, + Json(CompleteWithdrawalResponse { execution }), + ) + .into_response(), + 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 + .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("RelaySecret-1234567890-ABCDEFGHIJKL").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 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"}}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + 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); + } +}