From 55e584a2e03eab0d0869456a43743cca6222bae6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 10 Oct 2026 20:19:25 -0500 Subject: [PATCH 1/8] Prepare USDT fixture and Paykit peer for automated journeys --- .gitignore | 1 + .prettierignore | 5 +- README.md | 15 + USDT_INFRA_REVISION | 1 + docs/usdt-e2e.md | 75 +++++ scripts/build-ios-sim.sh | 7 + scripts/usdt-fixture | 30 ++ test/helpers/usdt.ts | 70 +++++ tools/paykit-fixture-sender/README.md | 69 ++++- tools/paykit-fixture-sender/src/main.rs | 367 +++++++++++++++++++++--- 10 files changed, 589 insertions(+), 51 deletions(-) create mode 100644 USDT_INFRA_REVISION create mode 100644 docs/usdt-e2e.md create mode 100755 scripts/usdt-fixture create mode 100644 test/helpers/usdt.ts diff --git a/.gitignore b/.gitignore index be5e506e..058d7415 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .DS_Store aut node_modules +.cache/usdt-infra docker/lnd docker/lnurl-server-data docker/lnurl-server/data diff --git a/.prettierignore b/.prettierignore index af6e842e..5ea2f020 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,5 @@ node_modules -aut \ No newline at end of file +aut +.cache/ +tools/**/target/ +tools/paykit-fixture-sender/state/ diff --git a/README.md b/README.md index 2e7f6a4b..043086a6 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,21 @@ To build from a worktree instead of `../bitkit-android` / `../bitkit-ios` (dirty --- +### USDT local execution + +[USDT E2E setup](docs/usdt-e2e.md) starts a pinned fixture from `bitkit-docker`, +passes local USDT endpoints into both app builds, and supplies funding/pending +controls plus a real Paykit SDK peer. Keep `BACKEND=regtest` for Paykit journeys; +VSS, Pubky, Bitcoin and rates retain their existing staging configuration. + +```sh +./scripts/usdt-fixture setup +USDT_TEST_ENV_FILE=/absolute/path/to/private.env ./scripts/usdt-fixture run +# Another terminal: +eval "$(./scripts/usdt-fixture env)" +./scripts/usdt-fixture smoke +``` + ### 🔐 Manual Trezor Emulator Setup The local docker setup includes an opt-in Trezor User Env fixture for manual hardware-wallet checks. It starts the official Trezor emulator and Bridge, but it is not part of the default `docker compose up -d` stack. diff --git a/USDT_INFRA_REVISION b/USDT_INFRA_REVISION new file mode 100644 index 00000000..52ca4e5b --- /dev/null +++ b/USDT_INFRA_REVISION @@ -0,0 +1 @@ +1af67098f65b119afdd7034dbdecfc61e9d6087b diff --git a/docs/usdt-e2e.md b/docs/usdt-e2e.md new file mode 100644 index 00000000..d48ad800 --- /dev/null +++ b/docs/usdt-e2e.md @@ -0,0 +1,75 @@ +# USDT E2E setup + +Use the existing staging Bitcoin/Paykit build, with USDT execution on the isolated Arbitrum fork. VSS, LNURL auth, Pubky and rate services remain on their existing staging configuration. No real USDT is needed. + +## Start the shared fixture + +The launcher consumes the exact `bitkit-docker` commit in `USDT_INFRA_REVISION`. Its shared services and test controls live there; this repository owns the Appium journeys and SDK peer. + +```bash +./scripts/usdt-fixture setup +# Private env file with ARBITRUM_RPC_URL=https://... (archive access to the pinned fork block). +USDT_TEST_ENV_FILE=/absolute/path/to/private.env ./scripts/usdt-fixture run +``` + +Keep that process running. In another shell: + +```bash +eval "$(./scripts/usdt-fixture env)" +BACKEND=regtest ANDROID_ROOT=/path/to/bitkit-android ./scripts/build-android-apk.sh +BACKEND=regtest IOS_ROOT=/path/to/bitkit-ios ./scripts/build-ios-sim.sh +``` + +Both app revisions must include USDT support (iOS #884 / Android #1432 or their descendants). Android embeds `USDT_*` through existing Gradle settings; iOS embeds them through the build helper, preserving them on reinstall and relaunch. The normal default branches need not expose USDT yet. Build and run with the same endpoints and `BACKEND=regtest`. + +For Android, set `ANDROID_SERIAL` to a dedicated test emulator. `prepareUsdtFixture()` installs its gateway port reverse. iOS uses host loopback directly. Do not reuse a funded mainnet app or reset another test suite's device. + +## Helpers for journeys + +`test/helpers/usdt.ts` provides: + +- `prepareUsdtFixture()` checks the live fixture and selected endpoints. +- `fundUsdt(address, '10')` executes a real incoming token transfer. +- `usdtBalance(address)` returns exact atomic units; one USDT is 1,000,000 units. +- `setUsdtBundling('manual')` holds new operations pending; use the `bundle` command to include them. +- `setUsdtProvider(...)` controls unavailable, expired and invalid-signature failures. Restore `healthy` in cleanup. +- `mineUsdtBlocks()` advances history scanning after a transfer. + +Keep using `ciIt`, onboarding, backup, polling and navigation helpers. Use exact on-chain deltas for payment assertions; the displayed fiat value uses the existing live rate feed. For backup tests, use the existing real VSS upload/restore helpers and preserve fixture state across the reinstall. `./scripts/usdt-fixture reset` is only for independent scenarios with matching app/peer resets; it also clears the bundler's pending operations. + +Record passing journeys with `RECORD_VIDEO=true SAVE_DIAGNOSTICS=true`. USDT specs should be opt-in and require the fixture; do not add them to existing staging tags until their matching app builds and infrastructure startup are configured in CI. CI must inspect the final attempts/artifacts, not only a job with `continue-on-error`. + +## Coverage handoff + +1. Direct receive/send: zero-ETH first operation, subsequent sends, exact recipient/fee deltas, activity and details. +2. Contact payments and requests: USDT-only/mixed endpoints, quoted BTC/USD conversion, endpoint restrictions, same-asset and rail rates, underpayment and late payment display. +3. Subscriptions: Bitkit's single-currency-family creation and external billing-period proofs. +4. Failure/recovery: quote expiry, invalid signatures, held inclusion, lost response, restart and VSS restore without double payment. +5. Purchases: use the compatible Paykit Server/Locks mixed profile in `bitkit-docker`; both verifier and app must use this fork. +6. Bridges: use local provider/relay scenarios and label their simulation boundary. Production operator delivery/refunds remain live acceptance. + +Most request cases can use one app plus the real SDK peer. Use a smaller iOS/Android pairing for interoperability. Avoid replicating every arithmetic/proof validation unit case through the UI. + +## Controlled Paykit peer + +The existing [fixture sender](../tools/paykit-fixture-sender/README.md#usdt-and-recurring-journeys) +now supports USDT-only/mixed endpoints, fixed or per-period recurring quotes, +payment deadlines, accepting incoming requests, submitting existing proofs and +exporting authenticated received evidence for Core verification. Build it with +`cargo build --locked --manifest-path tools/paykit-fixture-sender/Cargo.toml`. +Use only dedicated peers connected to this fork; an unrelated staging wallet or +server will query a different chain and cannot verify these payments. + +The infrastructure smoke suite verifies local execution and proof cryptography. +It does not run the full mobile, VSS, Shop or bridge journeys. Those are the tests +Piotr should add using the matrix above. The shared fixture docs record the +Anvil/Alto validation limits and Shop container routing requirements. Bridge +operator/relay scenario servers still need to be supplied by the bridge tests; +the gateway's local-provider configuration is the seam for them. + +For an initial manual device check, reuse onboarding and open the USDT wallet: +`UsdtBalance`, `UsdtReceiveDetails` and `UsdtReceiveAddress` identify the receive +view on both platforms. Fund that address through `fundUsdt`, then use the +existing `UsdtAmount`, `UsdtReview`, `UsdtConfirm` and `UsdtSendSuccess` selectors. +Assert the recipient's chain balance and recorded fee as well as the UI. Preserve +the same fork and peer through app relaunch/VSS restore scenarios. diff --git a/scripts/build-ios-sim.sh b/scripts/build-ios-sim.sh index cb5ed294..b16af63d 100755 --- a/scripts/build-ios-sim.sh +++ b/scripts/build-ios-sim.sh @@ -51,6 +51,13 @@ if [[ -n "$TREZOR_ELECTRUM_URL" ]]; then XCODE_EXTRA_ARGS+=("TREZOR_ELECTRUM_URL=$TREZOR_ELECTRUM_URL") fi +# Embed fixture endpoints so they survive Appium reinstalls and app relaunches. +for key in USDT_RPC_URL USDT_BUNDLER_URL USDT_DEPOSITS_URL USDT_BRIDGES_URL USDT_BRIDGE_NETWORKS; do + if [[ -n "${!key+x}" ]]; then + XCODE_EXTRA_ARGS+=("$key=${!key}") + fi +done + echo "Building iOS simulator app (BACKEND=$BACKEND, E2E_BACKEND=$E2E_BACKEND, TREZOR_BRIDGE=$TREZOR_BRIDGE, TREZOR_BRIDGE_URL=$TREZOR_BRIDGE_URL)..." xcodebuild \ diff --git a/scripts/usdt-fixture b/scripts/usdt-fixture new file mode 100755 index 00000000..16ed5ef8 --- /dev/null +++ b/scripts/usdt-fixture @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +INFRA=${BITKIT_DOCKER_ROOT:-$ROOT/.cache/usdt-infra} +REV=$(tr -d '[:space:]' < "$ROOT/USDT_INFRA_REVISION") + +if [ "${1:-help}" = setup ]; then + if [ ! -d "$INFRA" ]; then + mkdir -p "$INFRA" + git -C "$INFRA" init + git -C "$INFRA" remote add origin https://github.com/synonymdev/bitkit-docker.git + fi + if [ -z "${BITKIT_DOCKER_ROOT:-}" ]; then + if [ -n "$(git -C "$INFRA" status --porcelain)" ]; then + echo 'Infrastructure checkout has local edits; preserve them before setup.' >&2 + exit 1 + fi + git -C "$INFRA" fetch --depth 1 origin "$REV" + git -C "$INFRA" checkout --detach "$REV" + fi +fi +if ! git -C "$INFRA" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo 'Run ./scripts/usdt-fixture setup first.' >&2 + exit 1 +fi +if [ "$(git -C "$INFRA" rev-parse HEAD)" != "$REV" ]; then + echo "Use bitkit-docker revision $REV (see USDT_INFRA_REVISION)." >&2 + exit 1 +fi +exec "$INFRA/usdt-fixture" "$@" diff --git a/test/helpers/usdt.ts b/test/helpers/usdt.ts new file mode 100644 index 00000000..9d1fe626 --- /dev/null +++ b/test/helpers/usdt.ts @@ -0,0 +1,70 @@ +import { execFileSync } from 'node:child_process'; + +export interface UsdtFixtureStatus { + chainId: number; + forkBlock: number; + mode: string; + block: string; + urls: Record<'node' | 'alto' | 'gateway' | 'control', string>; +} + +export async function usdtFixture(method: string, params: unknown[] = []): Promise { + const endpoint = process.env.USDT_FIXTURE_URL; + if (!endpoint) throw new Error('Start scripts/usdt-fixture run and load its env output first'); + const url = new URL(endpoint); + if (url.protocol !== 'http:' || !['localhost', '127.0.0.1'].includes(url.hostname)) { + throw new Error('USDT fixture controls must use localhost HTTP'); + } + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) throw new Error(`USDT fixture HTTP ${response.status}`); + const body = (await response.json()) as { result: T; error?: { message: string } }; + if (body.error) throw new Error(body.error.message); + return body.result; +} + +/** Call before wallet setup; never reset the chain in the middle of a recovery journey. */ +export async function prepareUsdtFixture() { + const status = await usdtFixture('status'); + if (status.chainId !== 42161 || status.mode !== 'healthy') { + throw new Error('Expected a healthy Arbitrum fixture'); + } + for (const [key, route] of [ + ['USDT_RPC_URL', 'chain-rpc'], + ['USDT_BUNDLER_URL', 'rpc'], + ['USDT_DEPOSITS_URL', 'deposits'], + ['USDT_BRIDGES_URL', 'bridges'], + ]) { + if (process.env[key] !== `${status.urls.gateway}/v1/usdt/${route}`) { + throw new Error(`Load scripts/usdt-fixture env before building and running (${key})`); + } + } + if (driver.isAndroid) { + const serial = + process.env.ANDROID_SERIAL ?? + (driver.capabilities as Record)['appium:udid']?.toString(); + if (!serial) throw new Error('Set ANDROID_SERIAL for the dedicated test emulator'); + const port = new URL(status.urls.gateway).port; + execFileSync('adb', ['-s', serial, 'reverse', `tcp:${port}`, `tcp:${port}`]); + } + return status; +} + +/** Sends an actual ERC-20 transfer, producing receive/history events. Amount is decimal USDT. */ +export const fundUsdt = (address: string, amount: string) => + usdtFixture('fund', [address, amount]); + +/** Atomic units, suitable for exact balance and fee assertions. */ +export const usdtBalance = async (address: string) => + BigInt(await usdtFixture('balance', [address])); + +export const setUsdtProvider = ( + mode: 'healthy' | 'unavailable' | 'expired' | 'invalid-signature' +) => usdtFixture('provider-mode', [mode]); + +export const setUsdtBundling = (mode: 'auto' | 'manual') => usdtFixture('bundling', [mode]); +export const mineUsdtBlocks = (count = 3) => usdtFixture('mine', [count]); diff --git a/tools/paykit-fixture-sender/README.md b/tools/paykit-fixture-sender/README.md index 5709660b..db68de12 100644 --- a/tools/paykit-fixture-sender/README.md +++ b/tools/paykit-fixture-sender/README.md @@ -1,7 +1,8 @@ -# Fixed-price Paykit fixture sender +# Paykit fixture peer -Standalone rc71 sender for Bitkit iOS #887 / Android #1437. The SDK source is -pinned to Git revision `e4e58d3ee6c6aa19d6262d4cd96a58890a65b6fa`. No app rebuild is needed. +Real rc71 peer for Bitkit payment journeys. The SDK source is pinned to Git +revision `e4e58d3ee6c6aa19d6262d4cd96a58890a65b6fa`. For local USDT execution, +use app builds configured for the same fork as described below. Run from this directory: @@ -50,7 +51,7 @@ choose your requested amount/asset and optional fixed conversion rates: --address '' ``` -`--rate` is repeatable; its value means BTC per unit of the requested asset. +`--rate` is repeatable; its value means payment-asset units per unit of the requested asset. `btc-regtest` overrides `btc` for this sender's regtest on-chain endpoint. Passing any `--rate` replaces the preset's entire rates list. `--amount`, `--asset`, `--note`, and `--expires-in` also work with presets. Without a preset @@ -60,7 +61,7 @@ can be sent to exercise Bitkit's rejection behavior. The optional `expected_sats` preview rounds on-chain amounts upward; if its bounded arithmetic cannot represent the calculation it is null. Request terms retain their original decimal strings and are validated by the real rc71 SDK. -The sender currently supplies a regtest P2WPKH endpoint, not Lightning invoices. +The sender supplies regtest P2WPKH and/or Arbitrum USDT endpoints, not Lightning invoices. Repeat for the other payer. These commands create requests; they never broadcast Bitcoin transactions. For this confirmation journey, dismiss the sheet without @@ -119,3 +120,61 @@ that directory between commands, private and outside version control. When movin an existing issuer to this tool, use its original directory via `--state` rather than copying credentials into this repository. `preview` is offline and does not need initialization. + +## USDT and recurring journeys + +Use only the dedicated local fork described in [USDT E2E setup](../../docs/usdt-e2e.md). +The sender can publish a USDT endpoint, a Bitcoin endpoint, or both. It sends real +SDK messages over staging Pubky; it never executes a payment itself. + +```sh +# Receive into a named local Core wallet from bitkit-docker: +USDT_WALLET_NAME=receiver ./usdt-fixture wallet address +# Back in this tool's directory, insert that address: +./sender send '' --asset usd --amount 2.50 \ + --usdt-address '0x...' --rate usdt=1 --payment-in 3600 +# Add --address '' --rate btc-regtest=0.00002 for both choices. + +./sender preview --asset usd --amount 5 --usdt-address '0x...' --per-period \ + --recurrence '{"every":1,"unit":"month","starts_at":"2026-10-01T00:00:00Z","anchor":"2026-10-01T00:00:00Z","ends_at":null}' +``` + +`--payment-in` is the one-time **payment** deadline; `--expires-in` remains the +proposal acceptance deadline. Rates are payment-asset units per requested unit; +rail selectors override asset-wide selectors. Recurring requests can use fixed +rates or `--per-period`, which requires a later quote. The SDK validates terms +and lifecycle transitions. Use current/future periods in an actual journey. + +The peer can also act as the payer of a Bitkit-created request: + +```sh +./sender poll --seconds 5 +./sender accept '' '' +./sender proof '' '' proof-submission.json +./sender quote '' '' quote.json +``` + +A quote file contains `billing_period` (`starts_at`, `ends_at`), `rates` +(`asset`, `value`) and `expires_at`. A proof submission file contains +`payment_app_id`, `payment_endpoint_identifier`, optional `billing_period` and +`conversion_quote_id`, plus `proof`: the exact `erc20-transfer-eip712` object +from `./usdt-fixture wallet proof ID BINDING_FILE`. Pay the matching recipient +with `wallet send` first; retain its payment ID and immutable request binding. +Proof retries must reuse that payment, never run `send` again. + +To verify a payment from Bitkit: + +```sh +./sender poll --seconds 5 +./sender evidence '' '' > evidence.json +jq '.proofs[0].binding' evidence.json > binding.json +jq '.proofs[0].proof' evidence.json > proof.json +# From bitkit-docker, with absolute file paths: +USDT_WALLET_NAME=receiver ./usdt-fixture wallet verify /path/binding.json /path/proof.json +``` + +`evidence` derives payer/payee from the authenticated SDK record, not the proof's +claimed identity. It exports evidence; it does not declare the request paid. +Assert the verified amount, request price/deadline and unique payment identity in +the journey. `ProofSubmitted` alone is not payment verification. Save files under +ignored `state/` or the E2E artifacts directory; never commit test identity state. diff --git a/tools/paykit-fixture-sender/src/main.rs b/tools/paykit-fixture-sender/src/main.rs index e2d9b853..c5b77aae 100644 --- a/tools/paykit-fixture-sender/src/main.rs +++ b/tools/paykit-fixture-sender/src/main.rs @@ -3,6 +3,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use fs2::FileExt; use paykit::*; use serde::{Deserialize, Serialize}; +use serde_json::json; use std::{ collections::HashMap, fs, @@ -15,9 +16,10 @@ use std::{ const APP: &str = "paykit-server"; const ENDPOINT: &str = "btc-regtest-p2wpkh"; +const USDT_ENDPOINT: &str = "usdt-arbitrum-address"; #[derive(Parser)] -#[command(about = "Real rc71 Paykit sender for Bitkit fixed-price journeys")] +#[command(about = "Real Paykit peer for Bitkit payment journeys")] struct Args { #[arg(long, default_value = "state")] state: PathBuf, @@ -49,6 +51,22 @@ enum Command { #[command(flatten)] options: RequestOptions, }, + /// Accept a request received from Bitkit. Never sends funds. + Accept { peer: String, request: String }, + /// Submit an existing method proof; file contains the SDK proof submission fields. + Proof { + peer: String, + request: String, + file: PathBuf, + }, + /// Issue a recurring quote; file contains billing_period, rates and expires_at. + Quote { + peer: String, + request: String, + file: PathBuf, + }, + /// Export received evidence and its authenticated binding for local Core verification. + Evidence { peer: String, request: String }, /// Receive messages and retry delivery (including after a failed send). Poll { #[arg(long, default_value_t = 30)] @@ -68,7 +86,19 @@ enum Command { #[derive(clap::Args)] struct RequestOptions { #[arg(long)] - address: String, + address: Option, + /// Local-fork USDT receiving address; may be used alone or with --address. + #[arg(long)] + usdt_address: Option, + /// Recurrence object in the Paykit wire format. + #[arg(long)] + recurrence: Option, + /// Require a fresh quote for each recurring billing period. + #[arg(long, requires = "recurrence")] + per_period: bool, + /// One-time actual payment deadline, in seconds from now. + #[arg(long, conflicts_with = "recurrence")] + payment_in: Option, /// Requested denomination, e.g. btc, usd, usdt. #[arg(long)] asset: Option, @@ -175,7 +205,11 @@ fn terms(case: Preset, address: &str) -> Result<(FfiPaymentRequestTerms, serde_j custom_terms( Some(case), &RequestOptions { - address: address.into(), + address: Some(address.into()), + usdt_address: None, + recurrence: None, + per_period: false, + payment_in: None, asset: None, amount: None, rates: vec![], @@ -189,15 +223,39 @@ fn custom_terms( case: Option, options: &RequestOptions, ) -> Result<(FfiPaymentRequestTerms, serde_json::Value)> { - let address = &options.address; - let parsed: bitcoin::Address = - address.parse().context("invalid Bitcoin address")?; - let parsed = parsed - .require_network(bitcoin::Network::Regtest) - .context("address must be regtest")?; - if parsed.address_type() != Some(bitcoin::AddressType::P2wpkh) { - bail!("use a regtest P2WPKH address (bcrt1q...)"); + let mut endpoints = HashMap::new(); + if let Some(address) = &options.address { + let parsed: bitcoin::Address = + address.parse().context("invalid Bitcoin address")?; + let parsed = parsed + .require_network(bitcoin::Network::Regtest) + .context("address must be regtest")?; + if parsed.address_type() != Some(bitcoin::AddressType::P2wpkh) { + bail!("use a regtest P2WPKH address (bcrt1q...)"); + } + endpoints.insert( + ENDPOINT.to_string(), + serde_json::json!({"value":address}).to_string(), + ); + } + if let Some(address) = &options.usdt_address { + if address.len() != 42 + || !address.starts_with("0x") + || !address[2..].bytes().all(|b| b.is_ascii_hexdigit()) + || address[2..].bytes().all(|b| b == b'0') + { + bail!("use a nonzero 20-byte USDT address"); + } + endpoints.insert( + USDT_ENDPOINT.to_string(), + serde_json::json!({"value":address}).to_string(), + ); + } + if endpoints.is_empty() { + bail!("provide --address and/or --usdt-address"); } + let mut accepted: Vec = endpoints.keys().cloned().collect(); + accepted.sort(); let (default_value, default_asset, default_rates) = match case { Some(Preset::Usd) => ("10", "usd", vec![("btc", "0.000021")]), Some(Preset::Btc) => ("0.001", "btc", vec![("btc", "2"), ("btc-regtest", "0.5")]), @@ -237,11 +295,44 @@ fn custom_terms( let reference = format!("fixture-{:032x}", rand::random::()); let expiry = (chrono::Utc::now() + chrono::Duration::seconds(i64::from(options.expires_in))) .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let payload = serde_json::json!({"value":address}).to_string(); - let mut json = serde_json::json!({"amount":{"value":value,"asset":asset},"conversion":{"type":"fixed","rates":rates.iter().map(|(a,v)|serde_json::json!({"asset":a,"value":v})).collect::>()},"accepted_payment_endpoint_identifiers":[ENDPOINT],"payment_endpoints":{ENDPOINT:payload},"required_app_id":APP,"payment_reference":reference,"proposal_expires_at":expiry,"metadata":{"note":note},"expected_sats":sats}); - if rates.is_empty() { - json.as_object_mut().unwrap().remove("conversion"); - } + let recurrence = options + .recurrence + .as_deref() + .map(|text| -> Result<_> { + let value: serde_json::Value = serde_json::from_str(text)?; + Ok(FfiPaymentRequestRecurrence { + every: value["every"] + .as_u64() + .context("recurrence.every required")? + .try_into()?, + unit: required_text(&value, "unit")?, + starts_at: required_text(&value, "starts_at")?, + anchor: required_text(&value, "anchor")?, + ends_at: value["ends_at"].as_str().map(String::from), + }) + }) + .transpose()?; + let conversion = if options.per_period { + if !rates.is_empty() { + bail!("per-period quotes cannot have fixed request rates"); + } + Some(FfiPaymentConversion::PerPeriod) + } else { + (!rates.is_empty()).then(|| FfiPaymentConversion::Fixed { + rates: rates + .iter() + .map(|(a, v)| FfiConversionRate { + asset: (*a).into(), + value: (*v).into(), + }) + .collect(), + }) + }; + let payment_deadline = options.payment_in.map(|seconds| FfiPaymentDeadline::At { + timestamp: (chrono::Utc::now() + chrono::Duration::seconds(i64::from(seconds))) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + }); + let json = serde_json::json!({"amount":{"value":value,"asset":asset},"accepted_payment_endpoint_identifiers":accepted,"payment_endpoints":endpoints,"required_app_id":APP,"payment_reference":reference,"proposal_expires_at":expiry,"metadata":{"note":note},"expected_sats":sats}); let terms = FfiPaymentRequestTerms { amount: FfiPaymentRequestAmount { value: value.into(), @@ -249,26 +340,43 @@ fn custom_terms( }, payment_reference: Arc::new(FfiPaymentReference::new(reference)?), proposal_expires_at: Some(expiry), - recurrence: None, - accepted_payment_endpoint_identifiers: vec![ENDPOINT.into()], - payment_endpoints: Some(HashMap::from([(ENDPOINT.into(), payload)])), + recurrence, + accepted_payment_endpoint_identifiers: accepted, + payment_endpoints: Some(endpoints), required_app_id: Some(APP.into()), - conversion: (!rates.is_empty()).then(|| FfiPaymentConversion::Fixed { - rates: rates - .into_iter() - .map(|(a, v)| FfiConversionRate { - asset: a.into(), - value: v.into(), - }) - .collect(), - }), - payment_deadline: None, + conversion, + payment_deadline, metadata: Arc::new(FfiPrivateJsonObject::new(json["metadata"].to_string())?), }; let _: paykit_lib::PaymentRequestTerms = terms.clone().try_into()?; + let mut json = json; + if options.per_period { + json["conversion"] = json!({"type":"per_period"}); + } else if !rates.is_empty() { + json["conversion"] = json!({"type":"fixed","rates": rates.iter().map(|(a,v)| json!({"asset":a,"value":v})).collect::>()}); + } + if let Some(value) = &options.recurrence { + json["recurrence"] = serde_json::from_str(value)?; + } + if let Some(FfiPaymentDeadline::At { timestamp }) = &terms.payment_deadline { + json["payment_deadline"] = json!({"type":"at", "timestamp":timestamp}); + } Ok((terms, json)) } +fn required_text(value: &serde_json::Value, key: &str) -> Result { + Ok(value[key] + .as_str() + .with_context(|| format!("{key} must be a string"))? + .into()) +} +fn period(value: &serde_json::Value) -> Result { + Ok(FfiBillingPeriod { + starts_at: required_text(value, "starts_at")?, + ends_at: required_text(value, "ends_at")?, + }) +} + // Optional preview estimate; never changes the SDK's exact decimal request terms. fn estimate_sats(amount: &str, rate: &str) -> Option { fn decimal(s: &str) -> Option<(u128, u32)> { @@ -403,7 +511,7 @@ async fn main() -> Result<()> { private_payments: true, payment_requests: true, receipts: false, - outgoing_payments: false, + outgoing_payments: true, }, ) .await?; @@ -426,7 +534,7 @@ async fn main() -> Result<()> { } let identity: Identity = serde_json::from_slice(&fs::read(&identity_path).context("run init first")?)?; - println!( + eprintln!( "Issuer: {}", identity.public_key.as_deref().unwrap_or("unknown") ); @@ -468,13 +576,12 @@ async fn main() -> Result<()> { Command::Send { peer, case: _, - options, + options: _, } => { let peer = normalize_pubky_public_key(peer)?; let (terms, json) = prepared .take() .expect("send terms validated before session setup"); - let address = &options.address; if !sdk .linked_peers() .await? @@ -483,13 +590,18 @@ async fn main() -> Result<()> { { bail!("peer is not linked; run link first"); } - sdk.sync_public_endpoints_with_receiving_details(vec![FfiPublicReceivingDetail { - identifier: ENDPOINT.into(), - payload: Arc::new(FfiPaymentPayload::new( - serde_json::json!({"value":address}).to_string(), - )), - }]) - .await?; + let details = terms + .payment_endpoints + .as_ref() + .context("fixture endpoints missing")? + .iter() + .map(|(identifier, payload)| FfiPublicReceivingDetail { + identifier: identifier.clone(), + payload: Arc::new(FfiPaymentPayload::new(payload.clone())), + }) + .collect(); + sdk.sync_public_endpoints_with_receiving_details(details) + .await?; let record = sdk.propose_payment_request(peer.clone(), terms).await?; let receipt = serde_json::json!({"payment_request_id":record.payment_request_id,"terms":json,"sdk_revision":"e4e58d3ee6c6aa19d6262d4cd96a58890a65b6fa"}); atomic_write( @@ -498,10 +610,10 @@ async fn main() -> Result<()> { .join(format!("request-{}.json", record.payment_request_id)), &serde_json::to_vec_pretty(&receipt)?, )?; - println!( - "Request: {}\nExpected: {} sats", - record.payment_request_id, json["expected_sats"] - ); + println!("Request: {}", record.payment_request_id); + if let Some(sats) = json["expected_sats"].as_u64() { + println!("Expected: {sats} sats"); + } let report = sdk.process_outbound_private_messages(peer.clone()).await?; println!("Delivery: {:?}", report); let persisted = sdk @@ -516,6 +628,119 @@ async fn main() -> Result<()> { ); } } + Command::Accept { peer, request } => { + let peer = normalize_pubky_public_key(peer)?; + let record = sdk + .claim_and_accept_payment_request(peer.clone(), request) + .await?; + println!("Acceptance: {:?}", record.state); + println!( + "Delivery: {:?}", + sdk.process_outbound_private_messages(peer).await? + ); + } + Command::Proof { + peer, + request, + file, + } => { + let peer = normalize_pubky_public_key(peer)?; + let value: serde_json::Value = serde_json::from_slice(&fs::read(file)?)?; + let record = sdk + .submit_payment_proof( + peer.clone(), + request, + FfiPaymentProofSubmission { + billing_period: value + .get("billing_period") + .filter(|p| !p.is_null()) + .map(period) + .transpose()?, + payment_app_id: required_text(&value, "payment_app_id")?, + payment_endpoint_identifier: required_text( + &value, + "payment_endpoint_identifier", + )?, + allowance_id: None, + conversion_quote_id: value["conversion_quote_id"] + .as_str() + .map(String::from), + proof: Arc::new(FfiPrivateJsonObject::new(value["proof"].to_string())?), + }, + ) + .await?; + println!("Proof state: {:?}", record.state); + println!( + "Delivery: {:?}", + sdk.process_outbound_private_messages(peer).await? + ); + } + Command::Quote { + peer, + request, + file, + } => { + let peer = normalize_pubky_public_key(peer)?; + let value: serde_json::Value = serde_json::from_slice(&fs::read(file)?)?; + let rates = value["rates"] + .as_array() + .context("rates must be an array")? + .iter() + .map(|r| { + Ok(FfiConversionRate { + asset: required_text(r, "asset")?, + value: required_text(r, "value")?, + }) + }) + .collect::>()?; + let record = sdk + .quote_payment_request( + peer.clone(), + request, + period(&value["billing_period"])?, + rates, + required_text(&value, "expires_at")?, + ) + .await?; + for quote in record.conversion_quotes { + println!("Quote: {:?}", quote); + } + println!( + "Delivery: {:?}", + sdk.process_outbound_private_messages(peer).await? + ); + } + Command::Evidence { peer, request } => { + let peer = normalize_pubky_public_key(peer)?; + let record = sdk + .payment_requests_with(peer.clone()) + .await? + .into_iter() + .find(|record| record.payment_request_id == request) + .context("request not found; poll first")?; + let local = identity.public_key.context("fixture identity missing")?; + let (payer, payee) = match record.local_role { + Some(FfiPaymentRequestLocalRole::Payer) => (local, peer), + Some(FfiPaymentRequestLocalRole::Payee) => (peer, local), + _ => bail!("request has no authenticated role"), + }; + let proofs = record.payment_proofs.iter().map(|proof| -> Result<_> { + Ok(json!({"event_id":proof.event_id,"binding":{ + "payer":payer.strip_prefix("pubky").unwrap_or(&payer),"payee":payee.strip_prefix("pubky").unwrap_or(&payee),"payment_app_id":proof.payment_app_id, + "payment_request_id":request,"payment_reference":proof.payment_reference.export_text(), + "payment_endpoint_identifier":proof.payment_endpoint_identifier, + "period_starts_at":proof.billing_period.as_ref().map(|p|p.starts_at.as_str()).unwrap_or(""), + "period_ends_at":proof.billing_period.as_ref().map(|p|p.ends_at.as_str()).unwrap_or(""), + "conversion_quote_id":proof.conversion_quote_id.as_deref().unwrap_or("")}, + "proof":serde_json::from_str::(&proof.proof.export_text())?})) + }).collect::>>()?; + println!( + "{}", + serde_json::to_string_pretty( + &json!({"state":format!("{:?}",record.state),"proofs":proofs}) + )? + ); + } Command::Poll { seconds } => { let end = tokio::time::Instant::now() + Duration::from_secs(seconds); loop { @@ -598,7 +823,11 @@ mod tests { #[test] fn custom_rates_replace_presets_and_preserve_terms() { let mut options = RequestOptions { - address: address(), + address: Some(address()), + usdt_address: None, + recurrence: None, + per_period: false, + payment_in: None, asset: Some("usdt".into()), amount: Some("5.00".into()), rates: vec!["btc=0.00003".into()], @@ -630,6 +859,54 @@ mod tests { assert_eq!(json["expected_sats"], 10000); } #[test] + fn usdt_and_mixed_receiving_terms_validate_with_the_sdk() { + let mut options = RequestOptions { + address: None, + usdt_address: Some("0x1111111111111111111111111111111111111111".into()), + asset: Some("usd".into()), + amount: Some("2.50".into()), + rates: vec!["usdt=1".into()], + note: None, + expires_in: 3600, + recurrence: None, + per_period: false, + payment_in: Some(60), + }; + let (terms, json) = custom_terms(None, &options).unwrap(); + assert_eq!(terms.accepted_payment_endpoint_identifiers, [USDT_ENDPOINT]); + assert!(matches!( + terms.payment_deadline, + Some(FfiPaymentDeadline::At { .. }) + )); + assert_eq!(json["conversion"]["rates"][0]["value"], "1"); + options.address = Some(address()); + options.rates.push("btc-regtest=0.00002".into()); + let (terms, _) = custom_terms(None, &options).unwrap(); + assert_eq!( + terms.accepted_payment_endpoint_identifiers, + [ENDPOINT, USDT_ENDPOINT] + ); + options.usdt_address = Some("0x0".into()); + assert!(custom_terms(None, &options).is_err()); + } + #[test] + fn recurring_quotes_are_validated_as_distinct_from_fixed_request_rates() { + let mut options = RequestOptions { + address: None, + usdt_address: Some("0x1111111111111111111111111111111111111111".into()), + asset: Some("usd".into()), amount: Some("5".into()), rates: vec![], + note: None, expires_in: 3600, payment_in: None, per_period: true, + recurrence: Some(r#"{"every":1,"unit":"month","starts_at":"2026-10-01T00:00:00Z","anchor":"2026-10-01T00:00:00Z","ends_at":null}"#.into()), + }; + let (terms, _) = custom_terms(None, &options).unwrap(); + assert!(matches!( + terms.conversion, + Some(FfiPaymentConversion::PerPeriod) + )); + options.rates.push("usdt=1".into()); + assert!(custom_terms(None, &options).is_err()); + } + #[test] fn preview_estimate_rounds_up_and_handles_overflow() { assert_eq!(estimate_sats("0.05", "0.000012345"), Some(62)); assert_eq!(estimate_sats("10", "0.000021"), Some(21000)); From 0fa660aeb2d0a1dfbf327dabc45038a922b78f38 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 10 Oct 2026 20:47:28 -0500 Subject: [PATCH 2/8] Add bridge scenario controls for USDT E2E tests --- README.md | 9 ++++++++ USDT_INFRA_REVISION | 2 +- docs/usdt-e2e.md | 44 +++++++++++++++++++++++++++++++++++++-- test/helpers/usdt.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 043086a6..196b8dd3 100644 --- a/README.md +++ b/README.md @@ -425,3 +425,12 @@ BACKEND=regtest ./ci_run_ios.sh - To debug, add `console.info()` or enable `wdio` debug logs. - Use `ciIt()` instead of `it()` on CI to skip already-passing tests in retries. - In app-repo CI, E2E typically runs in three attempts with `continue-on-error: true`. If attempt 3 fails, it implies the same tests failed in attempts 1 and 2 (only failures are re-run), so check step logs/artifacts even if the job is green. + + +Bridge scenarios are included in the USDT fixture. Run +`./scripts/usdt-fixture bridge-smoke` before authoring UI tests. After the app +creates a deposit address, `./scripts/usdt-fixture orchestra deposit ADDRESS +polygon 3.5` creates a held external deposit; `orchestra advance ID completed` +credits the local Arbitrum wallet. Outgoing scenarios use the app's real source +transaction. See [bridge controls](docs/usdt-e2e.md#bridge-journeys) for helpers, +refunds, errors and the simulation boundary. diff --git a/USDT_INFRA_REVISION b/USDT_INFRA_REVISION index 52ca4e5b..56cb9ea0 100644 --- a/USDT_INFRA_REVISION +++ b/USDT_INFRA_REVISION @@ -1 +1 @@ -1af67098f65b119afdd7034dbdecfc61e9d6087b +81d91605412a57b8523dc047822332dc2041ade2 diff --git a/docs/usdt-e2e.md b/docs/usdt-e2e.md index d48ad800..7a8fb972 100644 --- a/docs/usdt-e2e.md +++ b/docs/usdt-e2e.md @@ -64,8 +64,8 @@ The infrastructure smoke suite verifies local execution and proof cryptography. It does not run the full mobile, VSS, Shop or bridge journeys. Those are the tests Piotr should add using the matrix above. The shared fixture docs record the Anvil/Alto validation limits and Shop container routing requirements. Bridge -operator/relay scenario servers still need to be supplied by the bridge tests; -the gateway's local-provider configuration is the seam for them. +operator/relay fixtures run with the shared stack; their controls drive normal +gateway/Core behavior without bypassing app verification. For an initial manual device check, reuse onboarding and open the USDT wallet: `UsdtBalance`, `UsdtReceiveDetails` and `UsdtReceiveAddress` identify the receive @@ -73,3 +73,43 @@ view on both platforms. Fund that address through `fundUsdt`, then use the existing `UsdtAmount`, `UsdtReview`, `UsdtConfirm` and `UsdtSendSuccess` selectors. Assert the recipient's chain balance and recorded fee as well as the UI. Preserve the same fork and peer through app relaunch/VSS restore scenarios. + + +## Bridge journeys + +The same fixture starts local Orchestra and LayerZero providers. Rebuild apps +with its latest `env` output to enable the USDT0 network selector too. All +provider traffic stays local; no real Orchestra API key is needed. + +- Incoming: open a bridge receive address in the app, then call + `createUsdtDeposit(owner, 'polygon', '3.5')`. Advance its returned ID with + `setUsdtBridgeStatus(id, 'processing')` and then `'completed'`. Completion + makes an actual token transfer to the app's Arbitrum address. Repeating it + does not credit funds twice. +- Outgoing Orchestra: send to BSC/Base/Tron/Solana, or select the cheaper quote + on a shared network. Find the funded quote in `usdtBridgeScenarios()` by owner + and recipient, then advance its ID. Before funding, advancing is rejected. +- Refunds: advance an outgoing quote to `'refunded'` to produce a real Arbitrum + refund receipt. For incoming deposits, request the refund through the app + before advancing it; its external-chain refund remains simulated. +- USDT0: use `setUsdtBridgeFee('100')` before a small Polygon send so Orchestra + cannot supply that quote. The deployed USDT0 contracts execute on the fork. + Pass the resulting source transaction to `setLayerZeroStatus` to test + `BLOCKED`, `DELIVERED`, or terminal `APPLICATION_BURNED` states. +- Errors: `setUsdtBridgeProvider` controls outages, throttling and invalid + responses separately for both providers. `setUsdtBridgeQuoteLifetime` sets + future Orchestra quote expiry; existing quotes stay immutable. + +Use `try/finally` to restore both provider modes to `healthy`, the fee to +`0.01` and quote lifetime to `120` seconds. Keep the fixture running through +app restart/reinstall. Core retains normal polling delays (30-60 seconds), so +use polling assertions rather than fixed short sleeps. A completed/refunded +Orchestra scenario cannot be switched to another outcome; create a fresh +payment for each terminal case. Reset clears provider scenarios and the chain +together, never just the app's funded payment. + +Run `./scripts/usdt-fixture bridge-smoke` to check the gateway/Core integration +before UI work. External deposit observation, off-Arbitrum delivery/refunds and +LayerZero relaying are simulated. Only actual local Arbitrum receipts establish +Arbitrum balance changes; do not assert destination-chain balances from a +simulated provider status. diff --git a/test/helpers/usdt.ts b/test/helpers/usdt.ts index 9d1fe626..5878b3a1 100644 --- a/test/helpers/usdt.ts +++ b/test/helpers/usdt.ts @@ -68,3 +68,52 @@ export const setUsdtProvider = ( export const setUsdtBundling = (mode: 'auto' | 'manual') => usdtFixture('bundling', [mode]); export const mineUsdtBlocks = (count = 3) => usdtFixture('mine', [count]); + +export type UsdtDepositNetwork = 'ethereum' | 'polygon' | 'base' | 'bsc' | 'tron' | 'solana'; +export type UsdtBridgeStatus = + | 'processing' + | 'needs_attention' + | 'failed' + | 'completed' + | 'refunding' + | 'refunded'; +export type LayerZeroStatus = + | 'INFLIGHT' + | 'CONFIRMING' + | 'DELIVERED' + | 'FAILED' + | 'BLOCKED' + | 'PAYLOAD_STORED' + | 'APPLICATION_BURNED' + | 'APPLICATION_SKIPPED'; + +/** Simulate an external deposit after the app has registered its receiving address. */ +export const createUsdtDeposit = (owner: string, network: UsdtDepositNetwork, amount: string) => + usdtFixture<{ id: string }>('orchestra', ['deposit', owner, network, amount]); + +/** Completion/refund creates real Arbitrum transfers where Arbitrum is the receiving chain. */ +export const setUsdtBridgeStatus = (id: string, status: UsdtBridgeStatus) => + usdtFixture('orchestra', ['advance', id, status]); + +export const usdtBridgeScenarios = () => + usdtFixture<{ + quotes: Array<{ id: string; owner: string; recipientAddress: string; order: unknown }>; + deposits: Array<{ deposit: { id: string; status: string }; order: unknown }>; + }>('orchestra', ['list']); + +/** Only changes future quotes; already approved/funded quotes retain their terms. */ +export const setUsdtBridgeFee = (amount: string) => usdtFixture('orchestra', ['fee', amount]); +export const setUsdtBridgeQuoteLifetime = (seconds: number) => + usdtFixture('orchestra', ['quote-ttl', String(seconds)]); + +/** Requires a real USDT0 source receipt; never fabricates a GUID or source payment. */ +export const setLayerZeroStatus = (sourceTx: string, status: LayerZeroStatus) => + usdtFixture('layerzero', [sourceTx, status]); + +export const setUsdtBridgeProvider = ( + provider: 'orchestra' | 'layerzero', + mode: 'healthy' | 'unavailable' | 'rate-limited' | 'invalid-response' +) => + provider === 'orchestra' + ? usdtFixture('orchestra', ['mode', mode]) + : usdtFixture('layerzero-mode', [mode]); From 14d77d25e43552231d73fac8999671a8fc922759 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 10 Oct 2026 21:43:22 -0500 Subject: [PATCH 3/8] Use the Docker USDT fixture in E2E setup --- README.md | 6 +++--- USDT_INFRA_REVISION | 2 +- docs/usdt-e2e.md | 9 ++++++--- scripts/usdt-fixture | 6 +++--- test/helpers/usdt.ts | 2 +- tools/paykit-fixture-sender/README.md | 13 ++++++++++--- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 196b8dd3..e9d30b61 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,9 @@ controls plus a real Paykit SDK peer. Keep `BACKEND=regtest` for Paykit journeys VSS, Pubky, Bitcoin and rates retain their existing staging configuration. ```sh -./scripts/usdt-fixture setup -USDT_TEST_ENV_FILE=/absolute/path/to/private.env ./scripts/usdt-fixture run -# Another terminal: +./scripts/usdt-fixture build +export USDT_TEST_ENV_FILE=/absolute/path/to/private.env +./scripts/usdt-fixture up eval "$(./scripts/usdt-fixture env)" ./scripts/usdt-fixture smoke ``` diff --git a/USDT_INFRA_REVISION b/USDT_INFRA_REVISION index 56cb9ea0..bafe38b2 100644 --- a/USDT_INFRA_REVISION +++ b/USDT_INFRA_REVISION @@ -1 +1 @@ -81d91605412a57b8523dc047822332dc2041ade2 +5e4df2d44e0950abe8ff47a62c01863aaaea49c0 diff --git a/docs/usdt-e2e.md b/docs/usdt-e2e.md index 7a8fb972..5cf3b2d0 100644 --- a/docs/usdt-e2e.md +++ b/docs/usdt-e2e.md @@ -7,12 +7,15 @@ Use the existing staging Bitcoin/Paykit build, with USDT execution on the isolat The launcher consumes the exact `bitkit-docker` commit in `USDT_INFRA_REVISION`. Its shared services and test controls live there; this repository owns the Appium journeys and SDK peer. ```bash -./scripts/usdt-fixture setup +./scripts/usdt-fixture build # Private env file with ARBITRUM_RPC_URL=https://... (archive access to the pinned fork block). -USDT_TEST_ENV_FILE=/absolute/path/to/private.env ./scripts/usdt-fixture run +export USDT_TEST_ENV_FILE=/absolute/path/to/private.env +./scripts/usdt-fixture up ``` -Keep that process running. In another shell: +Keep this Docker stack running through the journey. Build tools and services run inside containers; the host needs Docker Compose, Git access to the pinned Core/service repositories, jq and tar. Use `./scripts/usdt-fixture ps` or `logs` to inspect it, and `down` after testing to remove its containers and disposable wallet volume. CI must use a Docker-capable runner. + +In the same shell: ```bash eval "$(./scripts/usdt-fixture env)" diff --git a/scripts/usdt-fixture b/scripts/usdt-fixture index 16ed5ef8..b8b45d18 100755 --- a/scripts/usdt-fixture +++ b/scripts/usdt-fixture @@ -4,7 +4,7 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd) INFRA=${BITKIT_DOCKER_ROOT:-$ROOT/.cache/usdt-infra} REV=$(tr -d '[:space:]' < "$ROOT/USDT_INFRA_REVISION") -if [ "${1:-help}" = setup ]; then +if [ "${1:-help}" = build ]; then if [ ! -d "$INFRA" ]; then mkdir -p "$INFRA" git -C "$INFRA" init @@ -12,7 +12,7 @@ if [ "${1:-help}" = setup ]; then fi if [ -z "${BITKIT_DOCKER_ROOT:-}" ]; then if [ -n "$(git -C "$INFRA" status --porcelain)" ]; then - echo 'Infrastructure checkout has local edits; preserve them before setup.' >&2 + echo 'Infrastructure checkout has local edits; preserve them before building.' >&2 exit 1 fi git -C "$INFRA" fetch --depth 1 origin "$REV" @@ -20,7 +20,7 @@ if [ "${1:-help}" = setup ]; then fi fi if ! git -C "$INFRA" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo 'Run ./scripts/usdt-fixture setup first.' >&2 + echo 'Run ./scripts/usdt-fixture build first.' >&2 exit 1 fi if [ "$(git -C "$INFRA" rev-parse HEAD)" != "$REV" ]; then diff --git a/test/helpers/usdt.ts b/test/helpers/usdt.ts index 5878b3a1..15f226d9 100644 --- a/test/helpers/usdt.ts +++ b/test/helpers/usdt.ts @@ -10,7 +10,7 @@ export interface UsdtFixtureStatus { export async function usdtFixture(method: string, params: unknown[] = []): Promise { const endpoint = process.env.USDT_FIXTURE_URL; - if (!endpoint) throw new Error('Start scripts/usdt-fixture run and load its env output first'); + if (!endpoint) throw new Error('Start scripts/usdt-fixture up and load its env output first'); const url = new URL(endpoint); if (url.protocol !== 'http:' || !['localhost', '127.0.0.1'].includes(url.hostname)) { throw new Error('USDT fixture controls must use localhost HTTP'); diff --git a/tools/paykit-fixture-sender/README.md b/tools/paykit-fixture-sender/README.md index db68de12..6c677680 100644 --- a/tools/paykit-fixture-sender/README.md +++ b/tools/paykit-fixture-sender/README.md @@ -158,7 +158,7 @@ A quote file contains `billing_period` (`starts_at`, `ends_at`), `rates` (`asset`, `value`) and `expires_at`. A proof submission file contains `payment_app_id`, `payment_endpoint_identifier`, optional `billing_period` and `conversion_quote_id`, plus `proof`: the exact `erc20-transfer-eip712` object -from `./usdt-fixture wallet proof ID BINDING_FILE`. Pay the matching recipient +from `./usdt-fixture wallet proof ID /evidence/binding.json`. Pay the matching recipient with `wallet send` first; retain its payment ID and immutable request binding. Proof retries must reuse that payment, never run `send` again. @@ -169,10 +169,17 @@ To verify a payment from Bitkit: ./sender evidence '' '' > evidence.json jq '.proofs[0].binding' evidence.json > binding.json jq '.proofs[0].proof' evidence.json > proof.json -# From bitkit-docker, with absolute file paths: -USDT_WALLET_NAME=receiver ./usdt-fixture wallet verify /path/binding.json /path/proof.json +# From bitkit-docker, copy the two files created above into the read-only input mount: +mkdir -p .usdt/evidence +cp /absolute/path/to/binding.json /absolute/path/to/proof.json .usdt/evidence/ +USDT_WALLET_NAME=receiver ./usdt-fixture wallet verify /evidence/binding.json /evidence/proof.json ``` +Wallet commands run inside Docker. Put proof and binding input files in the +fixture checkout's `.usdt/evidence/` directory and pass `/evidence/...` paths. +The E2E launcher's default checkout is `.cache/usdt-infra/`; +`BITKIT_DOCKER_ROOT` selects another checkout. + `evidence` derives payer/payee from the authenticated SDK record, not the proof's claimed identity. It exports evidence; it does not declare the request paid. Assert the verified amount, request price/deadline and unique payment identity in From 97663411ee748bc413833e5e967cc160b25e6eac Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 10 Oct 2026 22:42:54 -0500 Subject: [PATCH 4/8] Pin USDT setup to the merged gateway service --- USDT_INFRA_REVISION | 2 +- docs/usdt-e2e.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/USDT_INFRA_REVISION b/USDT_INFRA_REVISION index bafe38b2..2a44d426 100644 --- a/USDT_INFRA_REVISION +++ b/USDT_INFRA_REVISION @@ -1 +1 @@ -5e4df2d44e0950abe8ff47a62c01863aaaea49c0 +6b97c7ad0d3f6b08b2d71bcdd6172a407f5a68f7 diff --git a/docs/usdt-e2e.md b/docs/usdt-e2e.md index 5cf3b2d0..285c8b43 100644 --- a/docs/usdt-e2e.md +++ b/docs/usdt-e2e.md @@ -15,6 +15,8 @@ export USDT_TEST_ENV_FILE=/absolute/path/to/private.env Keep this Docker stack running through the journey. Build tools and services run inside containers; the host needs Docker Compose, Git access to the pinned Core/service repositories, jq and tar. Use `./scripts/usdt-fixture ps` or `logs` to inspect it, and `down` after testing to remove its containers and disposable wallet volume. CI must use a Docker-capable runner. +Alto and the deployed payment contracts execute locally. A local signer with fixed pricing replaces Pimlico's hosted quote/authorization API; Orchestra and LayerZero responses simulate external bridge delivery. Local fees are test values, not production estimates. + In the same shell: ```bash From d2eb0e06e5b49725d4d97eaf9bf32946272d8d69 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 11 Oct 2026 00:15:36 -0500 Subject: [PATCH 5/8] Pin Shop setup and USDT startup fixes --- USDT_INFRA_REVISION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/USDT_INFRA_REVISION b/USDT_INFRA_REVISION index 2a44d426..492800e9 100644 --- a/USDT_INFRA_REVISION +++ b/USDT_INFRA_REVISION @@ -1 +1 @@ -6b97c7ad0d3f6b08b2d71bcdd6172a407f5a68f7 +13dab48759a7eb2e8eb7a9066069af840abbaa1b From 6f9ea0809a610cc06d30381a3db0e75367cf3196 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Sun, 11 Oct 2026 13:08:53 +0200 Subject: [PATCH 6/8] fix: publish complete USDT fixture endpoints and pin isolated images --- USDT_INFRA_REVISION | 2 +- tools/paykit-fixture-sender/src/main.rs | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/USDT_INFRA_REVISION b/USDT_INFRA_REVISION index 492800e9..48c394bd 100644 --- a/USDT_INFRA_REVISION +++ b/USDT_INFRA_REVISION @@ -1 +1 @@ -13dab48759a7eb2e8eb7a9066069af840abbaa1b +e233cbe286d67d6b965799ef60ec7da425354087 diff --git a/tools/paykit-fixture-sender/src/main.rs b/tools/paykit-fixture-sender/src/main.rs index c5b77aae..a4c46d99 100644 --- a/tools/paykit-fixture-sender/src/main.rs +++ b/tools/paykit-fixture-sender/src/main.rs @@ -248,7 +248,12 @@ fn custom_terms( } endpoints.insert( USDT_ENDPOINT.to_string(), - serde_json::json!({"value":address}).to_string(), + serde_json::json!({ + "value": address, + "chain_id": "42161", + "token": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9" + }) + .to_string(), ); } if endpoints.is_empty() { @@ -874,6 +879,13 @@ mod tests { }; let (terms, json) = custom_terms(None, &options).unwrap(); assert_eq!(terms.accepted_payment_endpoint_identifiers, [USDT_ENDPOINT]); + let payload: serde_json::Value = serde_json::from_str( + &terms.payment_endpoints.as_ref().unwrap()[USDT_ENDPOINT], + ) + .unwrap(); + assert_eq!(payload["value"], options.usdt_address.as_deref().unwrap()); + assert_eq!(payload["chain_id"], "42161"); + assert_eq!(payload["token"], "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"); assert!(matches!( terms.payment_deadline, Some(FfiPaymentDeadline::At { .. }) @@ -886,6 +898,13 @@ mod tests { terms.accepted_payment_endpoint_identifiers, [ENDPOINT, USDT_ENDPOINT] ); + assert_eq!( + serde_json::from_str::( + &terms.payment_endpoints.as_ref().unwrap()[USDT_ENDPOINT] + ) + .unwrap(), + payload + ); options.usdt_address = Some("0x0".into()); assert!(custom_terms(None, &options).is_err()); } From 4fcff698635f0d1a5d62e6bfc4fe3eaeb940d20e Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 11 Oct 2026 09:02:23 -0500 Subject: [PATCH 7/8] style: format Paykit fixture assertions --- tools/paykit-fixture-sender/src/main.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tools/paykit-fixture-sender/src/main.rs b/tools/paykit-fixture-sender/src/main.rs index a4c46d99..083b7bbf 100644 --- a/tools/paykit-fixture-sender/src/main.rs +++ b/tools/paykit-fixture-sender/src/main.rs @@ -879,13 +879,15 @@ mod tests { }; let (terms, json) = custom_terms(None, &options).unwrap(); assert_eq!(terms.accepted_payment_endpoint_identifiers, [USDT_ENDPOINT]); - let payload: serde_json::Value = serde_json::from_str( - &terms.payment_endpoints.as_ref().unwrap()[USDT_ENDPOINT], - ) - .unwrap(); + let payload: serde_json::Value = + serde_json::from_str(&terms.payment_endpoints.as_ref().unwrap()[USDT_ENDPOINT]) + .unwrap(); assert_eq!(payload["value"], options.usdt_address.as_deref().unwrap()); assert_eq!(payload["chain_id"], "42161"); - assert_eq!(payload["token"], "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"); + assert_eq!( + payload["token"], + "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9" + ); assert!(matches!( terms.payment_deadline, Some(FfiPaymentDeadline::At { .. }) From 3b2db8496715cc0f8b24a25000bcaef5aca4bbcc Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 11 Oct 2026 09:27:28 -0500 Subject: [PATCH 8/8] Use free-tier-compatible USDT fixture --- USDT_INFRA_REVISION | 2 +- docs/usdt-e2e.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/USDT_INFRA_REVISION b/USDT_INFRA_REVISION index 48c394bd..eed0d812 100644 --- a/USDT_INFRA_REVISION +++ b/USDT_INFRA_REVISION @@ -1 +1 @@ -e233cbe286d67d6b965799ef60ec7da425354087 +9e32a22fda16574afa1afb6772f0cdaba0fa81a9 diff --git a/docs/usdt-e2e.md b/docs/usdt-e2e.md index 285c8b43..31b80704 100644 --- a/docs/usdt-e2e.md +++ b/docs/usdt-e2e.md @@ -13,7 +13,9 @@ export USDT_TEST_ENV_FILE=/absolute/path/to/private.env ./scripts/usdt-fixture up ``` -Keep this Docker stack running through the journey. Build tools and services run inside containers; the host needs Docker Compose, Git access to the pinned Core/service repositories, jq and tar. Use `./scripts/usdt-fixture ps` or `logs` to inspect it, and `down` after testing to remove its containers and disposable wallet volume. CI must use a Docker-capable runner. +Keep this Docker stack running through the journey. Build tools and services run inside containers; the host needs Docker Compose, Git access to the pinned Core/service repositories, jq and tar. Use `./scripts/usdt-fixture ps` or `logs` to inspect it, and `down` after testing to remove its containers, disposable wallets and fork-read cache. CI must use a Docker-capable runner. + +An Alchemy Free Arbitrum endpoint is sufficient within its monthly quota. The fixture paces and caches archive-state reads and skips mainnet logs from before the fork; it does not use paid Debug/Trace APIs. Local receives, sends, activity and seed recovery still use real logs created on this fork. Historical mainnet wallet recovery is outside this setup. Use `./scripts/usdt-fixture status` to inspect upstream read counts. Alto and the deployed payment contracts execute locally. A local signer with fixed pricing replaces Pimlico's hosted quote/authorization API; Orchestra and LayerZero responses simulate external bridge delivery. Local fees are test values, not production estimates. @@ -65,7 +67,7 @@ exporting authenticated received evidence for Core verification. Build it with Use only dedicated peers connected to this fork; an unrelated staging wallet or server will query a different chain and cannot verify these payments. -The infrastructure smoke suite verifies local execution and proof cryptography. +The infrastructure smoke suite verifies local execution, proof cryptography, incoming history and recovery into an empty database. It does not run the full mobile, VSS, Shop or bridge journeys. Those are the tests Piotr should add using the matrix above. The shared fixture docs record the Anvil/Alto validation limits and Shop container routing requirements. Bridge