diff --git a/.gitignore b/.gitignore index be5e506..058d741 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 af6e842..5ea2f02 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 2e7f6a4..e9d30b6 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 build +export USDT_TEST_ENV_FILE=/absolute/path/to/private.env +./scripts/usdt-fixture up +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. @@ -410,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 new file mode 100644 index 0000000..eed0d81 --- /dev/null +++ b/USDT_INFRA_REVISION @@ -0,0 +1 @@ +9e32a22fda16574afa1afb6772f0cdaba0fa81a9 diff --git a/docs/usdt-e2e.md b/docs/usdt-e2e.md new file mode 100644 index 0000000..31b8070 --- /dev/null +++ b/docs/usdt-e2e.md @@ -0,0 +1,122 @@ +# 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 build +# Private env file with ARBITRUM_RPC_URL=https://... (archive access to the pinned fork block). +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, 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. + +In the same 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, 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 +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 +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/scripts/build-ios-sim.sh b/scripts/build-ios-sim.sh index cb5ed29..b16af63 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 0000000..b8b45d1 --- /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}" = build ]; 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 building.' >&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 build 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 0000000..15f226d --- /dev/null +++ b/test/helpers/usdt.ts @@ -0,0 +1,119 @@ +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 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'); + } + 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]); + +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]); diff --git a/tools/paykit-fixture-sender/README.md b/tools/paykit-fixture-sender/README.md index 5709660..6c67768 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,68 @@ 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 /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. + +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, 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 +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 e2d9b85..083b7bb 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,44 @@ 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, + "chain_id": "42161", + "token": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9" + }) + .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 +300,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 +345,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 +516,7 @@ async fn main() -> Result<()> { private_payments: true, payment_requests: true, receipts: false, - outgoing_payments: false, + outgoing_payments: true, }, ) .await?; @@ -426,7 +539,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 +581,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 +595,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 +615,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 +633,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 +828,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 +864,70 @@ 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]); + 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 { .. }) + )); + 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] + ); + 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()); + } + #[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));