Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.DS_Store
aut
node_modules
.cache/usdt-infra
docker/lnd
docker/lnurl-server-data
docker/lnurl-server/data
Expand Down
5 changes: 4 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
node_modules
aut
aut
.cache/
tools/**/target/
tools/paykit-fixture-sender/state/
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions USDT_INFRA_REVISION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
9e32a22fda16574afa1afb6772f0cdaba0fa81a9
122 changes: 122 additions & 0 deletions docs/usdt-e2e.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions scripts/build-ios-sim.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
30 changes: 30 additions & 0 deletions scripts/usdt-fixture
Original file line number Diff line number Diff line change
@@ -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" "$@"
119 changes: 119 additions & 0 deletions test/helpers/usdt.ts
Original file line number Diff line number Diff line change
@@ -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<T>(method: string, params: unknown[] = []): Promise<T> {
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<UsdtFixtureStatus>('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<string, unknown>)['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<string>('fund', [address, amount]);

/** Atomic units, suitable for exact balance and fee assertions. */
export const usdtBalance = async (address: string) =>
BigInt(await usdtFixture<string>('balance', [address]));

export const setUsdtProvider = (
mode: 'healthy' | 'unavailable' | 'expired' | 'invalid-signature'
) => usdtFixture<string>('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]);
Loading