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
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Run the shared Arbitrum fork, Alto and gateway without spending mainnet funds:
export USDT_TEST_ENV_FILE=/absolute/path/to/private.env
./usdt-fixture up
./usdt-fixture check
./usdt-fixture max-smoke
./usdt-fixture smoke
./usdt-fixture bridge-smoke
eval "$(./usdt-fixture env)"
Expand All @@ -33,6 +34,7 @@ SOURCE_TX DELIVERED` updates a real USDT0 source message. See [USDT fixture](doc
payment controls, proof verification, the optional Shop overlay and simulation limits.



## Services

- **Bitcoin Core** (regtest): Bitcoin node for development
Expand Down Expand Up @@ -966,6 +968,69 @@ curl -s http://localhost:23000/health | jq '.lnd.balance'
- Default credentials are used
- All services are exposed on localhost only

## MAX quotes

The fixture pins Core `0.8.0-rc4` and the gateway policy that allows only the
sender's USDT balance-slot override during gas estimation. `max-smoke` funds a
disposable wallet and requires an affordable MAX quote without submission or
changing its real balance. Use this `usdt` gateway for MAX; the older
`arbitrum-provider` fixture does not supply the required metadata and policy.
For a credential-free archive upstream, apply `bitkit-docker#27` to the same
fixture checkout before building; it supplies the existing public RPC option.

The test run owns app builds, device installation and mobile deadline/MAX checks.

For a seat whose service host cannot fetch private source, transfer the verified
images from a build host. Set `USDT_IMAGE_TAG` to the fixture build's reported
fingerprint, `FIXTURE_SOURCE_BOX` to that build host and `SERVICES_BOX` to the
seat's services host. This transfers images only:

```sh
ssh "$FIXTURE_SOURCE_BOX" docker image save \
"bitkit-docker/usdt-fixture:$USDT_IMAGE_TAG" \
"bitkit-docker/usdt-gateway:$USDT_IMAGE_TAG" | ssh "$SERVICES_BOX" docker image load
```

The seat's own fixture checkout must contain this fix and `bitkit-docker#27`.
Use its existing Compose project, and set `SEAT_PORT_OFFSET` to the seat's
published Bitcoin RPC port minus `43782`. In that checkout on the services host:

```sh
export USDT_PROJECT="$COMPOSE_PROJECT_NAME"
export USDT_GATEWAY_PORT=$((23453 + SEAT_PORT_OFFSET))
export USDT_CONTROL_PORT=$((23454 + SEAT_PORT_OFFSET))
./usdt-fixture up
./usdt-fixture check
./usdt-fixture max-smoke
./usdt-fixture status
curl -fsS "http://127.0.0.1:$USDT_GATEWAY_PORT/healthz"
```

The launcher computes the image fingerprint from the tracked fixture inputs;
require it to match the transferred tag. Require gateway `status=ok`, chain
`42161`, provider mode `healthy`, and an affordable MAX quote with
`submitted=false`. The smoke checks that an estimation override and final
paymaster authorization occurred, with unchanged real balances and no new
submission. Keep this chain alive throughout the app checks.

`./usdt-fixture env` prints all four app endpoint overrides. Forward the gateway
and control ports through the test run's existing relays. For Android on the
services host, use its own seat-specific adb server and serial:

```sh
adb -P "$ANDROID_ADB_SERVER_PORT" -s "$SERIAL" reverse tcp:23453 tcp:"$USDT_GATEWAY_PORT"
adb -P "$ANDROID_ADB_SERVER_PORT" -s "$SERIAL" reverse tcp:23454 tcp:"$USDT_CONTROL_PORT"
```

That Android build uses gateway `http://127.0.0.1:23453/v1/usdt`; iOS uses the
run's forwarded loopback gateway. Keep staging Bitcoin, Pubky and VSS settings.
Fund the app's displayed disposable Arbitrum account with
`./usdt-fixture fund ADDRESS 20`. Android Core `com.synonym:bitkit-core-android:0.8.0-rc4`
is published; build the run's rc4-pinned companion with `--refresh-dependencies`
and its existing Maven authentication. No local-AAR substitute is needed.
The test run owns app installation, MAX review and deadline-expiry proof on its
lane's devices. No device is launched on the coordinator.

## Production Considerations

Do not use for production. Bitkit Dev server is vibe-coded and not optimised.
Expand Down
3 changes: 3 additions & 0 deletions docs/usdt-fixture.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export USDT_TEST_ENV_FILE=/absolute/path/to/private.env
./usdt-fixture ps
./usdt-fixture check
./usdt-fixture smoke
./usdt-fixture max-smoke
./usdt-fixture bridge-smoke
./usdt-fixture fund 0xYOUR_TEST_WALLET 10
./usdt-fixture balance 0xYOUR_TEST_WALLET
Expand All @@ -43,6 +44,7 @@ eval "$(./usdt-fixture env)"
| `wallet address` | Address of the disposable Core wallet |
| `wallet send ADDRESS ATOMIC_AMOUNT [Destination]` | Quote and send through Core; optional destination uses `Polygon`, `Bsc`, etc. |
| `wallet quote ADDRESS ATOMIC_AMOUNT [Destination]` | Inspect a quote without paying |
| `wallet quote-max ADDRESS` | Inspect a MAX quote (the balance less Core's fee reserve) without paying |
| `wallet refresh ID` | Reconcile pending execution, including older receipts; Core may retry the identical signed operation |
| `wallet sync`, then `wallet history` | Scan local payment history; repeat `sync` until `complete` is true |
| `wallet proof ID BINDING_FILE` | Create the standard proof after execution |
Expand Down Expand Up @@ -154,5 +156,6 @@ The overlay is infrastructure wiring; a paid USDT Shop journey and browser unloc
./usdt-fixture up
./usdt-fixture check
./usdt-fixture smoke
./usdt-fixture max-smoke
./usdt-fixture bridge-smoke
```
3 changes: 2 additions & 1 deletion usdt-fixture
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ ENV
wallet)
compose run --rm --no-deps -e USDT_WALLET_NAME="${USDT_WALLET_NAME:-sender}" usdt-tools wallet "$@"
;;
smoke|bridge-smoke|status|balance|fund|mine|reset|provider-mode|bundling|bundle|orchestra|layerzero|layerzero-mode)
smoke|max-smoke|bridge-smoke|status|balance|fund|mine|reset|provider-mode|bundling|bundle|orchestra|layerzero|layerzero-mode)
compose run --rm --no-deps usdt-tools "$command" "$@"
;;
help|-h|--help)
Expand All @@ -87,6 +87,7 @@ Usage: ./usdt-fixture <command>
down Remove this project's containers, disposable wallets and fork cache
env Print app/helper endpoint exports
check Run fixture lint and protocol tests inside Docker (stack must be up)
max-smoke Check affordable MAX quotes without submission
smoke Check real Core payments, fees, pending recovery and proofs
bridge-smoke Check local bridge funding, receipts and refunds
wallet ARGS Run the Core test wallet; read proof/binding files from /evidence
Expand Down
1 change: 1 addition & 0 deletions usdt/cli.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { rpc } = require('./rpc.cjs');
const { state, urls } = require('./config.cjs');
async function main() {
const [command = 'help', ...args] = process.argv.slice(2);
if (command === 'max-smoke') return require('./max-smoke.cjs').smoke();
if (command === 'smoke') return require('./smoke.cjs').smoke();
if (command === 'bridge-smoke') return require('./bridge-smoke.cjs').smoke();
if (command === 'wallet') {
Expand Down
62 changes: 62 additions & 0 deletions usdt/max-smoke.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const assert = require('node:assert/strict');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const { randomUUID } = require('node:crypto');
const { mkdtempSync, rmSync } = require('node:fs');
const path = require('node:path');
const { rpc } = require('./rpc.cjs');
const { state, urls } = require('./config.cjs');
const exec = promisify(execFile);

async function smoke() {
const directory = mkdtempSync(path.join(state, 'max-smoke-'));
const runId = randomUUID();
const wallet = async (name, ...args) => {
const { stdout } = await exec('usdt-fixture-wallet', args, {
env: {
...process.env,
USDT_WALLET_DIR: directory,
USDT_WALLET_NAME: `${runId}-${name}`,
},
timeout: 120000,
});
return JSON.parse(stdout);
};
try {
assert.equal((await rpc(urls.control, 'status')).mode, 'healthy');
const sender = (await wallet('sender', 'address')).address;
const recipient = (await wallet('recipient', 'address')).address;
await rpc(urls.control, 'fund', [sender, '20']);
const before = BigInt(await rpc(urls.control, 'balance', [sender]));
const callsBefore = (await rpc(urls.control, 'status')).max;
const quote = await wallet('sender', 'quote-max', recipient);
const callsAfter = (await rpc(urls.control, 'status')).max;
const calls = Object.fromEntries(
Object.keys(callsAfter).map((key) => [key, callsAfter[key] - callsBefore[key]]),
);
// Core's MAX quote takes at most three fee rounds, each one override estimate and one
// paymaster authorization; the counters are the provider's, so nothing else may quote meanwhile.
assert(
calls.estimatesWithOverride >= 1 && calls.estimatesWithOverride <= 3,
JSON.stringify(calls),
);
assert(
calls.paymasterAuthorizations >= 1 && calls.paymasterAuthorizations <= 3,
JSON.stringify(calls),
);
assert.equal(calls.submissions, 0, JSON.stringify(calls));
assert(quote.amount > 0 && quote.maximum_fee > 0);
// MAX quotes the whole balance: the amount is the balance less the fee reserve.
assert.equal(BigInt(quote.amount) + BigInt(quote.maximum_fee), before);
assert.equal(BigInt(await rpc(urls.control, 'balance', [sender])), before);
assert.equal(BigInt(await rpc(urls.control, 'balance', [recipient])), 0n);
assert.equal(BigInt(await rpc(urls.node, 'eth_getBalance', [sender, 'latest'])), 0n);
assert.equal(await rpc(urls.node, 'eth_getCode', [sender, 'latest']), '0x');
console.log(
JSON.stringify({ passed: true, balance: before.toString(), quote, calls, submitted: false }),
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
module.exports = { smoke };
4 changes: 2 additions & 2 deletions usdt/pins.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"linux_arm64": "5bdce3dade8b6f0a65d4fccf6908426aee4905a988b08dce79d0a72fe7498310",
"linux_amd64": "6c66ffcc55fa4249197721baa3098bc208014ea1d8aa04b2ed50ac6bccffb226"
},
"service": "3d8c0b625b02f0731ff0a19910752961ada3e6f2",
"core": "bdce512c9d1025ba6cd71baa8c2df1a39dcb3d02"
"service": "ada7f6a55e7f2478ebcc8404153c06f6433d9e9f",
"core": "1ebbd776d83acd1748cd9b32375d5aa420ebb4aa"
}
6 changes: 6 additions & 0 deletions usdt/server.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ async function run() {
// Shop's verifier can read this fork over the private Compose network.
servers.push(await serve(ports.reader, upstreamReader(urls.node), '0.0.0.0'));
let mode = 'healthy';
const max = { estimatesWithOverride: 0, paymasterAuthorizations: 0, submissions: 0 };
servers.push(
await serve(ports.provider, async (method, params) => {
if (mode === 'unavailable')
Expand All @@ -79,8 +80,12 @@ async function run() {
],
};
if (method === 'pm_getPaymasterStubData' || method === 'pm_getPaymasterData') {
if (method === 'pm_getPaymasterData') max.paymasterAuthorizations++;
return paymasterData(urls.node, params, mode);
}
if (method === 'eth_estimateUserOperationGas' && params[2] !== undefined)
max.estimatesWithOverride++;
if (method === 'eth_sendUserOperation') max.submissions++;
return rpc(urls.alto, method, params);
}),
);
Expand All @@ -99,6 +104,7 @@ async function run() {
chainId: 42161,
forkBlock: pins.forkBlock,
mode,
max: { ...max },
urls: {
...urls,
gateway: `http://127.0.0.1:${process.env.USDT_GATEWAY_PORT ?? ports.gateway}`,
Expand Down
2 changes: 1 addition & 1 deletion usdt/wallet/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion usdt/wallet/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ async fn main() -> Result<()> {
let result = match command {
"address" => json!({"address":address}),
"balance" => json!({"address":address,"balance":wallet.balance().await?}),
"quote-max" => {
let recipient = args.get(1).context("quote-max requires recipient")?;
json!(wallet.quote_max_transfer(recipient.clone(), UsdtDestination::Arbitrum).await?)
}
"quote" | "send" => {
let recipient = args.get(1).context("send requires recipient and atomic amount")?;
let amount: u64 = args.get(2).context("send requires atomic amount")?.parse()?;
Expand Down Expand Up @@ -120,7 +124,7 @@ async fn main() -> Result<()> {
}).await?;
verified.map(|p| json!({"payment_id":p.payment_id,"transfer_id":p.transfer_id,"sender":p.sender,"recipient":p.recipient,"amount":p.amount,"timestamp":p.timestamp})).unwrap_or(Value::Null)
}
_ => bail!("use address, balance, quote/send RECIPIENT ATOMIC_AMOUNT [Destination], deposit, refresh ID, sync, history, proof ID BINDING_FILE, or verify BINDING_FILE PROOF_FILE"),
_ => bail!("use address, balance, quote/send RECIPIENT ATOMIC_AMOUNT [Destination], quote-max RECIPIENT, deposit, refresh ID, sync, history, proof ID BINDING_FILE, or verify BINDING_FILE PROOF_FILE"),
};
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
Expand Down