From 5a66350eb70005b44f64594fc155bbf6e9f5b1d8 Mon Sep 17 00:00:00 2001 From: karczuRF Date: Fri, 23 Oct 2026 16:26:52 +0200 Subject: [PATCH 1/3] feat(lore-0021): publish sep-41 events from the test token The test token moved storage without events, so upstream's exact verifier, which reads the simulated SEP-41 transfer event, refused every payment in it with no_transfer_events. transfer, approve and mint now publish their SEP-41 events, and a test checks the literal topics and data the verifier reads. --- contracts/test-token/Cargo.toml | 3 + contracts/test-token/src/lib.rs | 125 +++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/contracts/test-token/Cargo.toml b/contracts/test-token/Cargo.toml index 3c6e8864..28320e72 100644 --- a/contracts/test-token/Cargo.toml +++ b/contracts/test-token/Cargo.toml @@ -12,3 +12,6 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/test-token/src/lib.rs b/contracts/test-token/src/lib.rs index 17564758..ec0eae22 100644 --- a/contracts/test-token/src/lib.rs +++ b/contracts/test-token/src/lib.rs @@ -5,8 +5,40 @@ //! Allowances follow SEP-41: `approve` overwrites, carries an expiration ledger, and an //! expired allowance reads as 0. `mint` is unauthenticated test scaffolding: anyone can mint, so //! never deploy it outside testnet. +//! +//! Events follow SEP-41 too: `transfer` with topics `[from, to]` and the amount as data, `approve` +//! with `[from, spender]` and `[amount, expiration_ledger]`, and `mint` with `[to]` and the +//! amount. The Stellar Asset Contract adds the SEP-11 asset as a fourth topic; a non-SAC token has +//! none. Upstream's `exact` verifier reads the simulated `transfer` event (its first three topics +//! and the bare amount), so a token without it can't be paid in (0021). + +use soroban_sdk::{contract, contractevent, contractimpl, contracttype, Address, Env, String}; + +#[contractevent(topics = ["transfer"], data_format = "single-value")] +struct Transfer { + #[topic] + from: Address, + #[topic] + to: Address, + amount: i128, +} -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; +#[contractevent(topics = ["approve"], data_format = "vec")] +struct Approve { + #[topic] + from: Address, + #[topic] + spender: Address, + amount: i128, + expiration_ledger: u32, +} + +#[contractevent(topics = ["mint"], data_format = "single-value")] +struct Mint { + #[topic] + to: Address, + amount: i128, +} #[contracttype] #[derive(Clone)] @@ -52,12 +84,19 @@ fn move_balance(env: &Env, from: &Address, to: &Address, amount: i128) { assert!(from_balance >= amount, "insufficient balance"); write_balance(env, from, from_balance - amount); write_balance(env, to, read_balance(env, to) + amount); + Transfer { + from: from.clone(), + to: to.clone(), + amount, + } + .publish(env); } #[contractimpl] impl TestToken { pub fn mint(env: Env, to: Address, amount: i128) { write_balance(&env, &to, read_balance(&env, &to) + amount); + Mint { to, amount }.publish(&env); } pub fn balance(env: Env, id: Address) -> i128 { @@ -82,7 +121,7 @@ impl TestToken { amount == 0 || expiration_ledger >= seq, "expiration_ledger is in the past" ); - let key = TokenKey::Allowance(from, spender); + let key = TokenKey::Allowance(from.clone(), spender.clone()); env.storage().temporary().set( &key, &AllowanceValue { @@ -98,6 +137,13 @@ impl TestToken { .extend_ttl(&key, live_for, live_for); } } + Approve { + from, + spender, + amount, + expiration_ledger, + } + .publish(&env); } pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { @@ -130,3 +176,78 @@ impl TestToken { String::from_str(&env, "TEST") } } + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{ + testutils::Address as _, testutils::Events as _, vec, IntoVal, Symbol, Val, Vec, + }; + + /// The event as upstream's `exact` verifier reads it: literal topics and data, not this + /// crate's own structs, so a change to their shape fails here. + fn event( + env: &Env, + token: &Address, + topics: Vec, + data: Val, + ) -> Vec<(Address, Vec, Val)> { + vec![env, (token.clone(), topics, data)] + } + + #[test] + fn publishes_sep41_events_for_mint_approve_and_transfers() { + let env = Env::default(); + env.mock_all_auths(); + let token = env.register(TestToken, ()); + let client = TestTokenClient::new(&env, &token); + let (from, spender, to) = ( + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ); + let amount = |n: i128| -> Val { n.into_val(&env) }; + + client.mint(&from, &100); + assert_eq!( + env.events().all().filter_by_contract(&token), + event( + &env, + &token, + (Symbol::new(&env, "mint"), from.clone()).into_val(&env), + amount(100) + ) + ); + client.transfer(&from, &to, &30); + assert_eq!( + env.events().all().filter_by_contract(&token), + event( + &env, + &token, + (Symbol::new(&env, "transfer"), from.clone(), to.clone()).into_val(&env), + amount(30) + ) + ); + client.approve(&from, &spender, &50, &1_000); + assert_eq!( + env.events().all().filter_by_contract(&token), + event( + &env, + &token, + (Symbol::new(&env, "approve"), from.clone(), spender.clone()).into_val(&env), + vec![&env, amount(50), 1_000u32.into_val(&env)].into_val(&env) + ) + ); + // `transfer_from` moves `from`'s tokens, so its event names `from`, not the spender. + client.transfer_from(&spender, &from, &to, &20); + assert_eq!( + env.events().all().filter_by_contract(&token), + event( + &env, + &token, + (Symbol::new(&env, "transfer"), from, to).into_val(&env), + amount(20) + ) + ); + } +} From a6a6c539ce45a178b326b5a9cd0d79097a66632d Mon Sep 17 00:00:00 2001 From: karczuRF Date: Fri, 23 Oct 2026 16:26:52 +0200 Subject: [PATCH 2/3] feat(lore-0021): add the testnet token matrix script One exact payment each in testnet USDC, a self-issued SAC and the non-SAC test token goes through /verify and /settle of a facilitator built by main(), with only the asset changing, then a concurrent batch across four channels. The script restores archived state from the facilitator account first and commits its results with every transaction hash. --- .../scripts/results/token-matrix-testnet.json | 133 ++++++ apps/facilitator/scripts/token-matrix.ts | 380 ++++++++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 apps/facilitator/scripts/results/token-matrix-testnet.json create mode 100644 apps/facilitator/scripts/token-matrix.ts diff --git a/apps/facilitator/scripts/results/token-matrix-testnet.json b/apps/facilitator/scripts/results/token-matrix-testnet.json new file mode 100644 index 00000000..cb1c09b1 --- /dev/null +++ b/apps/facilitator/scripts/results/token-matrix-testnet.json @@ -0,0 +1,133 @@ +{ + "ranAt": "2026-10-23T14:16:58.968Z", + "network": "stellar:testnet", + "facilitator": "GBCSI5BKVUU7EYSPFQMRQU2BLNHI2VT3HJRKEHHPOQI3AQ3FPE3FG2EI", + "channels": [ + "GAORS7OHSTLKALSGTBJJQLD67UYIF32OBT5JHBALC3FGKD56JSM7K4JZ", + "GDNC65R6VZMGYT4VLXESKZFCTBEKY7HB3Y3HRLJQXYLB33T5ZAUWCONG", + "GBMCFSPRWIGB7KQOKL7WRQ6I57DVCXEE3EMTI4HGVAGZCJ7D4ETV4NV6", + "GDX2LAQJ6S5IDELFGS43S7H7URWF3CRC4QRRWMGGP3GBMKYNI3DRPXS2" + ], + "buyer": "GCADNNOZTTKEGX3Z2TA6O2MLPVPMPXL3OLI6UWWQNWZSEANFDOMNE6F6", + "seller": "GCA7TADGTMSMUEDWMBVJCV3674FGDDFXWJROOHQHMP54QFX2Q7J4AZOF", + "amount": "1000", + "buyerAccount": { + "before": { + "xlm": "99999999800", + "sequence": "21842039003938817" + }, + "after": { + "xlm": "99999999800", + "sequence": "21842039003938817" + } + }, + "restoredBeforehand": { + "usdc": 0, + "sac": 0, + "sep41": 0 + }, + "matrix": [ + { + "kind": "usdc", + "contract": "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + "verified": true, + "settled": true, + "sellerReceived": "1000", + "hash": "c5ce6c24d406db4b47186c90b57c69de52e20374aa7d430906755f4dcf80bee1", + "status": "SUCCESS", + "ledger": 5348278, + "channel": "GAORS7OHSTLKALSGTBJJQLD67UYIF32OBT5JHBALC3FGKD56JSM7K4JZ", + "feePayer": "GBCSI5BKVUU7EYSPFQMRQU2BLNHI2VT3HJRKEHHPOQI3AQ3FPE3FG2EI", + "feeCharged": "11948" + }, + { + "kind": "sac", + "contract": "CCEAOQFKZWE2UXIU3CHKLCABAWP2QTK5IA46FMNTPTLFPWE27ZQG7DM5", + "verified": true, + "settled": true, + "sellerReceived": "1000", + "hash": "e1f4cb9381a0b74190398baae5016fa72e9091356680bb046a50815f8173e49c", + "status": "SUCCESS", + "ledger": 5348281, + "channel": "GDNC65R6VZMGYT4VLXESKZFCTBEKY7HB3Y3HRLJQXYLB33T5ZAUWCONG", + "feePayer": "GBCSI5BKVUU7EYSPFQMRQU2BLNHI2VT3HJRKEHHPOQI3AQ3FPE3FG2EI", + "feeCharged": "11995" + }, + { + "kind": "sep41", + "contract": "CBFIYRHJG4C4E4AU3K5XCCMSZKZR3IMVFPCKNAJ3OBRQCHDLPCT3GV53", + "verified": true, + "settled": true, + "sellerReceived": "1000", + "hash": "4c7808428da492a602911a7191a0f2f8a291e5d38bd90af7df435361e6a45248", + "status": "SUCCESS", + "ledger": 5348283, + "channel": "GBMCFSPRWIGB7KQOKL7WRQ6I57DVCXEE3EMTI4HGVAGZCJ7D4ETV4NV6", + "feePayer": "GBCSI5BKVUU7EYSPFQMRQU2BLNHI2VT3HJRKEHHPOQI3AQ3FPE3FG2EI", + "feeCharged": "152649" + } + ], + "batch": { + "size": 6, + "token": "CBFIYRHJG4C4E4AU3K5XCCMSZKZR3IMVFPCKNAJ3OBRQCHDLPCT3GV53", + "transactions": [ + { + "hash": "894dab7bb9bde26f85dbe3865836ec91630d58bef34e13f0b095af87881adddf", + "status": "SUCCESS", + "ledger": 5348285, + "channel": "GDX2LAQJ6S5IDELFGS43S7H7URWF3CRC4QRRWMGGP3GBMKYNI3DRPXS2", + "feePayer": "GBCSI5BKVUU7EYSPFQMRQU2BLNHI2VT3HJRKEHHPOQI3AQ3FPE3FG2EI", + "feeCharged": "10516" + }, + { + "hash": "dc8cf318615b2b77e528747a8f791acabd452a8cd26c47666653f0c6785ce937", + "status": "SUCCESS", + "ledger": 5348286, + "channel": "GAORS7OHSTLKALSGTBJJQLD67UYIF32OBT5JHBALC3FGKD56JSM7K4JZ", + "feePayer": "GBCSI5BKVUU7EYSPFQMRQU2BLNHI2VT3HJRKEHHPOQI3AQ3FPE3FG2EI", + "feeCharged": "10516" + }, + { + "hash": "a5ad83b8c654d5bc74036b6e5193dba4730303b9b2122c5ef86757b466f16d15", + "status": "SUCCESS", + "ledger": 5348286, + "channel": "GDX2LAQJ6S5IDELFGS43S7H7URWF3CRC4QRRWMGGP3GBMKYNI3DRPXS2", + "feePayer": "GBCSI5BKVUU7EYSPFQMRQU2BLNHI2VT3HJRKEHHPOQI3AQ3FPE3FG2EI", + "feeCharged": "10516" + }, + { + "hash": "4475ef33909c98436083083aacfa744cd5bb9a6302cb6d632f3c5286f6149f0c", + "status": "SUCCESS", + "ledger": 5348285, + "channel": "GBMCFSPRWIGB7KQOKL7WRQ6I57DVCXEE3EMTI4HGVAGZCJ7D4ETV4NV6", + "feePayer": "GBCSI5BKVUU7EYSPFQMRQU2BLNHI2VT3HJRKEHHPOQI3AQ3FPE3FG2EI", + "feeCharged": "10516" + }, + { + "hash": "355082892c83cf1a9335a62bdf0abaef3b88d9b74a59eb32ecfbe6146c52e337", + "status": "SUCCESS", + "ledger": 5348285, + "channel": "GDNC65R6VZMGYT4VLXESKZFCTBEKY7HB3Y3HRLJQXYLB33T5ZAUWCONG", + "feePayer": "GBCSI5BKVUU7EYSPFQMRQU2BLNHI2VT3HJRKEHHPOQI3AQ3FPE3FG2EI", + "feeCharged": "10516" + }, + { + "hash": "99028575f85f617f97876c20961c048c0303d1311f13a1c7e26988f7caa52da9", + "status": "SUCCESS", + "ledger": 5348285, + "channel": "GAORS7OHSTLKALSGTBJJQLD67UYIF32OBT5JHBALC3FGKD56JSM7K4JZ", + "feePayer": "GBCSI5BKVUU7EYSPFQMRQU2BLNHI2VT3HJRKEHHPOQI3AQ3FPE3FG2EI", + "feeCharged": "10516" + } + ] + }, + "checks": { + "everyTokenSettled": true, + "facilitatorPaidEveryFee": true, + "buyerPaidNoXlm": true, + "buyerSourcedNothing": true, + "batchSettled": true, + "batchUsedSeveralChannels": true, + "batchSharedALedgerAcrossChannels": true + } +} diff --git a/apps/facilitator/scripts/token-matrix.ts b/apps/facilitator/scripts/token-matrix.ts new file mode 100644 index 00000000..92ef5dd2 --- /dev/null +++ b/apps/facilitator/scripts/token-matrix.ts @@ -0,0 +1,380 @@ +/** + * Testnet token matrix (0021): one `exact` payment in each kind of SEP-41 token, verified and + * settled through a facilitator built by `main()`, then a concurrent batch across its channels. + * + * 1. Testnet USDC, a self-issued SAC (`UPTOE2E`) and the non-SAC test token each verify + * and settle through the same code: only `asset` changes in the requirements. + * 2. The buyer's XLM balance and sequence number are the same before and after: the facilitator + * pays every fee, and the buyer sources no transaction. + * 3. A batch of concurrent payments settles, and one ledger holds settlements from more than one + * channel. + * + * Env: + * CLIENT_SECRET the buyer: 0004's e2e client (`client` in + * `contracts/upto-proxy/e2e/secrets/accounts.json`), which holds testnet USDC; + * USDC only comes from Circle's faucet + * ISSUER_SECRET optional, `UPTOE2E`'s issuer (the same file, `issuer`), to top the buyer up + * TEST_TOKEN optional, another deployment of `contracts/test-token` (deploy-contract.sh) + * AMOUNT base units per payment (default 1000) + * BATCH payments in the concurrent batch (default 6) + * RPC_URLS passed to the facilitator (default: the testnet RPC) + * + * cd apps/facilitator + * CLIENT_SECRET=S… npx tsx scripts/token-matrix.ts + * + * The facilitator, its 4 channels and the seller are new each run. The results, with every hash, + * go to `scripts/results/token-matrix-testnet.json`; log lines go to stderr. + */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { + Account, + Address, + Asset, + Contract, + FeeBumpTransaction, + Keypair, + Networks, + Operation, + SorobanDataBuilder, + TransactionBuilder, + nativeToScVal, + rpc, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; +import { buildCreateChannelsTx, readAccountEntry } from "@stellar-x402/signer-pool"; +import type { PaymentRequirements, SettleResponse, VerifyResponse } from "@x402/core/types"; +import { createEd25519Signer } from "@x402/stellar"; +import { ExactStellarScheme } from "@x402/stellar/exact/client"; +import { Agent, fetch } from "undici"; +import { main } from "../src/main.js"; + +const network = "stellar:testnet"; +const server = new rpc.Server("https://soroban-testnet.stellar.org"); +/** Circle's testnet USDC. */ +const USDC = new Asset("USDC", "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); +/** 0004's self-issued asset; its SAC is deployed. */ +const OWN = new Asset("UPTOE2E", "GD36A3UITJRQD7NTYM43GB6YDGAZOO4XF2FXSSN2C5XWJQVCIRJWNARZ"); +/** + * The non-SAC SEP-41 test token (`contracts/test-token`), whose `mint` takes no auth: okarcz's + * `deploy/scripts/deploy-contract.sh test-token`, the first with SEP-41 events. WASM SHA-256 + * `d3108f813b0d5f0cb6c400626d7f4d0cf02bacd267afa8b6f9ba7b7f7aa83d31`. + */ +const TEST_TOKEN = + process.env.TEST_TOKEN ?? "CBFIYRHJG4C4E4AU3K5XCCMSZKZR3IMVFPCKNAJ3OBRQCHDLPCT3GV53"; +const CHANNELS = 4; +const AMOUNT = BigInt(process.env.AMOUNT ?? "1000"); +const BATCH = Number(process.env.BATCH ?? "6"); + +const tokens = [ + { kind: "usdc", contract: USDC.contractId(Networks.TESTNET) }, + { kind: "sac", contract: OWN.contractId(Networks.TESTNET) }, + { kind: "sep41", contract: TEST_TOKEN }, +] as const; + +/** The script's own HTTP client: `main()`'s global one gives up after 10 s without headers. */ +const http = new Agent({ headersTimeout: 120_000 }); + +async function friendbot(): Promise { + const keypair = Keypair.random(); + const response = await fetch(`https://friendbot.stellar.org/?addr=${keypair.publicKey()}`, { + dispatcher: http, + }); + if (!response.ok) throw new Error(`friendbot: ${response.status}`); + return keypair; +} + +async function submit(source: Keypair, operations: xdr.Operation[], soroban = false) { + const builder = new TransactionBuilder(await server.getAccount(source.publicKey()), { + fee: "10000", + networkPassphrase: Networks.TESTNET, + }).setTimeout(60); + for (const operation of operations) builder.addOperation(operation); + const tx = soroban ? await server.prepareTransaction(builder.build()) : builder.build(); + tx.sign(source); + await sendAndPoll(tx, "setup transaction"); +} + +/** Sends `tx` and waits for it, with the send's own error when the network refuses it. */ +async function sendAndPoll(tx: Parameters[0], what: string) { + const sent = await server.sendTransaction(tx); + if (sent.status !== "PENDING") { + const reason = sent.errorResult?.result().switch().name ?? "no error result"; + throw new Error(`${what}: send ${sent.status} (${reason})`); + } + const done = await server.pollTransaction(sent.hash, { attempts: 30 }); + if (done.status !== "SUCCESS") throw new Error(`${what}: ${done.status}`); +} + +async function tokenBalance(token: string, holder: string, source: string): Promise { + const tx = new TransactionBuilder(new Account(source, "0"), { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation(new Contract(token).call("balance", Address.fromString(holder).toScVal())) + .setTimeout(30) + .build(); + const sim = await server.simulateTransaction(tx); + if (!rpc.Api.isSimulationSuccess(sim) || !sim.result) throw new Error(`balance on ${token}`); + return BigInt(scValToNative(sim.result.retval) as bigint); +} + +/** XLM and sequence number: an unchanged sequence shows the buyer sourced no transaction. */ +async function xlmAndSequence(address: string): Promise<{ xlm: string; sequence: string }> { + const entry = await readAccountEntry(server, address); + if (!entry) throw new Error(`${address} does not exist`); + return { xlm: entry.balance().toString(), sequence: entry.seqNum().toString() }; +} + +/** Where a settlement landed and who paid for it, read back from the chain. */ +async function onChain(hash: string) { + const tx = await server.pollTransaction(hash, { attempts: 30 }); + if (tx.status !== "SUCCESS") return { hash, status: tx.status }; + const envelope = TransactionBuilder.fromXDR(tx.envelopeXdr.toXDR("base64"), Networks.TESTNET); + const [channel, feePayer] = + envelope instanceof FeeBumpTransaction + ? [envelope.innerTransaction.source, envelope.feeSource] + : [envelope.source, envelope.source]; + return { + hash, + status: tx.status, + ledger: tx.ledger, + channel, + feePayer, + feeCharged: tx.resultXdr.feeCharged().toString(), + }; +} + +const clientSecret = process.env.CLIENT_SECRET; +if (!clientSecret) throw new Error("CLIENT_SECRET: the buyer that holds testnet USDC (see header)"); +const client = Keypair.fromSecret(clientSecret); +const [facilitator, seller] = [await friendbot(), await friendbot()]; +const source = facilitator.publicKey(); + +// The buyer's balances: USDC is checked, the two others are topped up when low. +const usdcHeld = await tokenBalance(tokens[0].contract, client.publicKey(), source); +const needed = AMOUNT * BigInt(1 + BATCH); +if (usdcHeld < AMOUNT) { + throw new Error( + `the buyer holds ${usdcHeld} USDC base units: send testnet USDC to ${client.publicKey()} ` + + `from https://faucet.circle.com (Stellar testnet), then rerun`, + ); +} +if ((await tokenBalance(tokens[1].contract, client.publicKey(), source)) < AMOUNT) { + if (!process.env.ISSUER_SECRET) throw new Error("the buyer is out of UPTOE2E: set ISSUER_SECRET"); + const issuer = Keypair.fromSecret(process.env.ISSUER_SECRET); + await submit(issuer, [ + Operation.payment({ destination: client.publicKey(), asset: OWN, amount: "100" }), + ]); +} +if ((await tokenBalance(TEST_TOKEN, client.publicKey(), source)) < needed) { + // The facilitator pays for the mint, so the buyer's XLM stays untouched. + await submit( + facilitator, + [ + Operation.invokeContractFunction({ + contract: TEST_TOKEN, + function: "mint", + args: [ + Address.fromString(client.publicKey()).toScVal(), + nativeToScVal(1_000_000_000n, { type: "i128" }), + ], + }), + ], + true, + ); +} +// The seller holds the classic assets through trustlines. +await submit(seller, [ + Operation.changeTrust({ asset: USDC }), + Operation.changeTrust({ asset: OWN }), +]); + +/** + * Restores what the buyer's transfer would restore, paid by the facilitator account, and says how + * many entries. Since Protocol 23 a transaction restores the archived entries it touches and pays + * their rent: 0004's `UPTOE2E` instance and the buyer's old test-token balance, archived since + * its runs, put the buyer's transaction above `MAX_FEE_STROOPS`, and the facilitator refused it. + */ +async function restoreArchived(token: string): Promise { + const transfer = new TransactionBuilder(new Account(client.publicKey(), "0"), { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + new Contract(token).call( + "transfer", + Address.fromString(client.publicKey()).toScVal(), + Address.fromString(seller.publicKey()).toScVal(), + nativeToScVal(AMOUNT, { type: "i128" }), + ), + ) + .setTimeout(30) + .build(); + const sim = await server.simulateTransaction(transfer); + if (!rpc.Api.isSimulationSuccess(sim)) throw new Error(`transfer on ${token}: ${sim.error}`); + const data = sim.transactionData.build(); + const ext = data.ext(); + const archived: number[] = + ext.switch() === 1 ? (ext.value() as xdr.SorobanResourcesExtV0).archivedSorobanEntries() : []; + if (archived.length === 0) return 0; + const readWrite = data.resources().footprint().readWrite(); + const restore = new TransactionBuilder(await server.getAccount(source), { + fee: "10000", + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.restoreFootprint({})) + .setSorobanData( + new SorobanDataBuilder().setReadWrite(archived.map((i) => readWrite[i]!)).build(), + ) + .setTimeout(60) + .build(); + const prepared = await server.prepareTransaction(restore); + prepared.sign(facilitator); + await sendAndPoll(prepared, `restore on ${token}`); + return archived.length; +} +// One at a time: every restore comes from the facilitator account's sequence. +const restored: Record = {}; +for (const token of tokens) restored[token.kind] = await restoreArchived(token.contract); + +// Delegated channels: master key off, the facilitator their only signer. +const channelKeys = Array.from({ length: CHANNELS }, () => Keypair.random()); +const facilitatorEntry = await readAccountEntry(server, source); +if (!facilitatorEntry) throw new Error("the facilitator account does not exist"); +const setup = buildCreateChannelsTx({ + facilitator: new Account(source, facilitatorEntry.seqNum().toString()), + channels: channelKeys, + networkPassphrase: Networks.TESTNET, + fee: 1000, +}); +setup.sign(facilitator, ...channelKeys); +await sendAndPoll(setup, "channel setup"); + +const facilitatorServer = await main( + { + FACILITATOR_SECRET: facilitator.secret(), + CHANNELS: channelKeys.map((keypair) => keypair.publicKey()).join(","), + PORT: "0", + LOG_LEVEL: "warn", + RPC_URLS: process.env.RPC_URLS, + }, + { write: (line) => process.stderr.write(line) }, +); + +const payer = new ExactStellarScheme(createEd25519Signer(client.secret(), network)); +const requirementsFor = (asset: string): PaymentRequirements => ({ + scheme: "exact", + network, + asset, + amount: AMOUNT.toString(), + payTo: seller.publicKey(), + maxTimeoutSeconds: 60, + extra: { areFeesSponsored: true }, +}); +const post = async (route: string, requirements: PaymentRequirements, payload: unknown) => { + const response = await fetch(`http://127.0.0.1:${facilitatorServer.port}/${route}`, { + dispatcher: http, + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + x402Version: 2, + paymentPayload: payload, + paymentRequirements: requirements, + }), + }); + if (response.status !== 200) throw new Error(`/${route} answered ${response.status}`); + return (await response.json()) as T; +}; +/** One payment: the same steps for every token. */ +async function pay(asset: string) { + const requirements = requirementsFor(asset); + const payload = { + ...(await payer.createPaymentPayload(2, requirements)), + accepted: requirements, + }; + const verified = await post("verify", requirements, payload); + const settled = await post("settle", requirements, payload); + return { verified, settled, chain: settled.success ? await onChain(settled.transaction) : null }; +} + +try { + const buyerBefore = await xlmAndSequence(client.publicKey()); + const matrix = []; + for (const token of tokens) { + const sellerBefore = await tokenBalance(token.contract, seller.publicKey(), source); + const { verified, settled, chain } = await pay(token.contract); + const sellerAfter = await tokenBalance(token.contract, seller.publicKey(), source); + matrix.push({ + ...token, + verified, + settled, + chain, + sellerReceived: `${sellerAfter - sellerBefore}`, + }); + } + const batch = await Promise.all(Array.from({ length: BATCH }, () => pay(TEST_TOKEN))); + const buyerAfter = await xlmAndSequence(client.publicKey()); + + const landed = batch.map((payment) => payment.chain).filter((tx) => tx?.status === "SUCCESS"); + // The channels each ledger's batch settlements came through. + const perLedger = new Map>(); + for (const tx of landed) + perLedger.set(tx.ledger!, (perLedger.get(tx.ledger!) ?? new Set()).add(tx.channel!)); + const checks = { + everyTokenSettled: matrix.every( + (row) => + row.verified.isValid && + row.settled.success && + row.chain?.status === "SUCCESS" && + row.sellerReceived === AMOUNT.toString(), + ), + facilitatorPaidEveryFee: [...matrix.map((row) => row.chain), ...landed].every( + (tx) => tx?.feePayer === source, + ), + buyerPaidNoXlm: buyerAfter.xlm === buyerBefore.xlm, + buyerSourcedNothing: buyerAfter.sequence === buyerBefore.sequence, + batchSettled: landed.length === BATCH, + batchUsedSeveralChannels: new Set(landed.map((tx) => tx.channel)).size > 1, + batchSharedALedgerAcrossChannels: [...perLedger.values()].some((channels) => channels.size > 1), + }; + const results = { + ranAt: new Date().toISOString(), + network, + facilitator: source, + channels: channelKeys.map((keypair) => keypair.publicKey()), + buyer: client.publicKey(), + seller: seller.publicKey(), + amount: AMOUNT.toString(), + buyerAccount: { before: buyerBefore, after: buyerAfter }, + restoredBeforehand: restored, + matrix: matrix.map(({ kind, contract, verified, settled, chain, sellerReceived }) => ({ + kind, + contract, + verified: verified.isValid, + ...(verified.invalidReason && { invalidReason: verified.invalidReason }), + settled: settled.success, + ...(settled.errorReason && { errorReason: settled.errorReason }), + sellerReceived, + ...chain, + })), + batch: { + size: BATCH, + token: TEST_TOKEN, + transactions: batch.map( + ({ verified, settled, chain }) => + chain ?? { invalidReason: verified.invalidReason, errorReason: settled.errorReason }, + ), + }, + checks, + }; + mkdirSync(new URL("./results/", import.meta.url), { recursive: true }); + writeFileSync( + new URL("./results/token-matrix-testnet.json", import.meta.url), + `${JSON.stringify(results, null, 2)}\n`, + ); + console.log(JSON.stringify(results, null, 2)); + if (!Object.values(checks).every(Boolean)) process.exitCode = 1; +} finally { + await facilitatorServer.close(); +} From b6addb95a0bdde46382a40f71073730878026767 Mon Sep 17 00:00:00 2001 From: karczuRF Date: Fri, 23 Oct 2026 16:26:52 +0200 Subject: [PATCH 3/3] docs(lore-0021): record the token matrix run and spawn 0066 --- .../0021_FEATURE_testnet-token-matrix.md | 74 ++++++++++++++++++- .../0066_BUG_archived-state-restore-fee.md | 46 ++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 lore/1-tasks/backlog/0066_BUG_archived-state-restore-fee.md diff --git a/lore/1-tasks/active/0021_FEATURE_testnet-token-matrix.md b/lore/1-tasks/active/0021_FEATURE_testnet-token-matrix.md index 1767f6e3..d674354e 100644 --- a/lore/1-tasks/active/0021_FEATURE_testnet-token-matrix.md +++ b/lore/1-tasks/active/0021_FEATURE_testnet-token-matrix.md @@ -5,7 +5,7 @@ type: FEATURE status: active milestone: 1 related_adr: ["0007"] -related_tasks: ["0012", "0009", "0018"] +related_tasks: ["0012", "0009", "0018", "0004", "0066"] tags: [facilitator, testnet, exact, priority-medium, effort-small, payments] links: - ../../../docs/planning/m1-epics.md @@ -43,6 +43,72 @@ As a seller pricing in any SEP-41 token, I want evidence that verify and settle ## Acceptance Criteria -- [ ] Given testnet USDC, a self-issued SAC and a non-SAC SEP-41 token, when a scripted run verifies and settles a payment in each through the facilitator on testnet, then every payment settles and the buyer pays no XLM fee; there is no per-token code path and no asset allowlist -- [ ] Given a batch of concurrent payments, when the run settles them across several channels, then more than one settles in the same ledger -- [ ] Given the run's output, when it finishes, then the results, with every transaction hash, are committed to the repo +- [x] Given testnet USDC, a self-issued SAC and a non-SAC SEP-41 token, when a scripted run verifies and settles a payment in each through the facilitator on testnet, then every payment settles and the buyer pays no XLM fee; there is no per-token code path and no asset allowlist +- [x] Given a batch of concurrent payments, when the run settles them across several channels, then more than one settles in the same ledger +- [x] Given the run's output, when it finishes, then the results, with every transaction hash, are committed to the repo + +## Implementation Notes + +- `apps/facilitator/scripts/token-matrix.ts`: friendbot funds a facilitator and a seller, the + seller opens USDC and `UPTOE2E` trustlines, `buildCreateChannelsTx` makes 4 channels, and + `main()` starts the facilitator. Each token then goes through one `pay(asset)`: `/verify`, + `/settle`, the transaction read back from the chain (ledger, inner source = channel, fee + source, fee charged) and the seller's balance before and after. A batch of 6 test-token + payments is sent to `/settle` at once. The buyer's XLM and sequence number are read before + the first payment and after the batch. +- Results: `apps/facilitator/scripts/results/token-matrix-testnet.json`, every hash, ledger, + channel and fee, addresses only. Committed run (2026-10-23, 14:16 UTC, after the code review): + every check passed. USDC settled in ledger 5348278 (fee 11,948 stroops), the SAC in 5348281 + (11,995), the test token in 5348283 (152,649, most of it the rent of the seller's new balance + entry). Four of the six batch payments landed in ledger 5348285 through four different + channels, the other two in 5348286. The buyer's XLM (9,999.99998) and sequence number were the + same before and after. The two runs before the review's changes passed too (the last at + 14:05 UTC). +- The committed run restored nothing (`restoredBeforehand` all 0): the earlier runs had already + restored 0004's `UPTOE2E` instance and the buyer's old test-token balance through + `restoreArchived()`, which is how the restore path was tried on testnet. +- No per-token path: the script changes only `asset` in the requirements, and a search for an + asset allowlist in `apps/facilitator/src` and `packages/signer-pool/src` finds none. +- `contracts/test-token` now publishes SEP-41 events (`transfer`, `approve`, `mint`), with a Rust + test against the literal topics and data upstream's verifier reads; deployed by + `deploy/scripts/deploy-contract.sh test-token` at + `CBFIYRHJG4C4E4AU3K5XCCMSZKZR3IMVFPCKNAJ3OBRQCHDLPCT3GV53` (WASM SHA-256 `d3108f81…3d31`). + +## Issues Encountered + +- **0004's test token emitted no events.** Its `transfer` only moved storage, and upstream's + `exact` verifier reads the simulated SEP-41 `transfer` event, so it answered + `invalid_exact_stellar_payload_no_transfer_events`. 0004's upto path never looks at events, + which is why nobody saw it. Fixed in the contract (decision 3); 0004's upto e2e deploys the + token through the same script, so its next run gets the new ID. +- **Archived state made valid payments too expensive.** 0004's `UPTOE2E` SAC instance and the + buyer's old test-token balance were archived. Since Protocol 23 the buyer's transaction + restores them and pays their rent: 551,787 and 342,189 stroops of resource fee, above + `MAX_FEE_STROOPS` (250,000), so `/verify` answered `fee_exceeds_maximum`. The script restores + such entries first (decision 4); what the facilitator should do is 0066. +- **Restores from one account clash when run together** (sequence numbers), so they run one at a + time. +- **A testnet RPC error on one run:** the buyer's simulation answered "setting ContractComputeV0 + is not present in the snapshot" once, after testnet moved to protocol 29; the rerun passed. +- **Code review (2026-10-23):** the event test compared the contract with its own structs, so a + change of shape would pass; it now builds the topics and data by hand, and fails on a + map-shaped `transfer`. The SAC-shape claim was wrong (the SAC adds the SEP-11 asset as a fourth + topic). The script now reports a refused send with its status, checks the buyer's sequence + number, and checks that one ledger holds settlements from more than one channel. + +## Design Decisions + +### From Plan + +1. **One script beside the smoke runs** (okarcz, 2026-10-23): `apps/facilitator/scripts/token-matrix.ts`, run with `npx tsx`, built on `settle-smoke.ts` (a facilitator from `main()`, upstream's `exact` client). Not a vitest e2e package: four checks don't need one. Results go to `apps/facilitator/scripts/results/token-matrix-testnet.json`. +2. **The buyer is 0004's e2e client** (okarcz, 2026-10-23), passed as `CLIENT_SECRET`: testnet USDC comes only from Circle's faucet, and that account already holds it, so a run needs no manual step. The self-issued SAC is 0004's `UPTOE2E`; the facilitator, its channels and the seller are new each run. + +### Emerged + +3. **The test token gets SEP-41 events, and a new deployment** (okarcz, 2026-10-23): our own code in `contracts/test-token`, in the shape the SAC uses (`transfer` `[from, to]` with the amount as data, `approve` `[from, spender]` with `[amount, expiration_ledger]`, `mint` `[to]` with the amount), not a third-party token. The script names the deployment as a constant, overridable with `TEST_TOKEN`, rather than running `deploy-contract.sh` each time as 0004's e2e does, so the matrix needs no Rust toolchain. +4. **The script restores archived state before it pays** (okarcz, 2026-10-23): it simulates each token's transfer and, when the simulation lists archived entries, restores them from the facilitator account, the same way for every token. The results record how many entries each token needed (`restoredBeforehand`). The facilitator's own behaviour is 0066, a backlog task. +5. **The no-XLM-fee check compares the buyer's XLM and sequence number before the first payment and after the batch**: the same XLM shows nobody charged the buyer, the same sequence that the buyer sourced no transaction. Setup steps that touch the buyer (the test-token mint, a restore) are paid by the facilitator account and run before the first reading. + +## Future Work + +- **0066**: decide what `/verify` does when a payment must restore archived state. diff --git a/lore/1-tasks/backlog/0066_BUG_archived-state-restore-fee.md b/lore/1-tasks/backlog/0066_BUG_archived-state-restore-fee.md new file mode 100644 index 00000000..6f13613b --- /dev/null +++ b/lore/1-tasks/backlog/0066_BUG_archived-state-restore-fee.md @@ -0,0 +1,46 @@ +--- +id: "0066" +title: "Decide what /verify does when a payment must restore archived state" +type: BUG +status: backlog +milestone: 1 +related_adr: [] +related_tasks: ["0021", "0007"] +tags: [facilitator, exact, fees, payments, priority-medium, effort-small] +links: [] +history: + - date: "2026-10-23" + status: backlog + who: okarcz + note: "Spawned from 0021's testnet run." +--- + +# Decide what /verify does when a payment must restore archived state + +## Summary + +Since Protocol 23 a transaction restores the archived entries it touches and pays their rent. A +buyer's `exact` payment that touches archived state, such as a token balance unused for weeks or +a SAC instance nobody kept alive, then costs far more: in 0021's run, 551,787 stroops of resource +fee for a SAC whose instance was archived and 342,189 for a non-SAC token whose payer balance was +archived, against 14,278 for live USDC. Both are above `MAX_FEE_STROOPS` (250,000), so `/verify` +answers `invalid_exact_stellar_payload_fee_exceeds_maximum`: a valid payment is refused, and the +buyer can't tell why or what would fix it. + +0021's script restores such entries from the facilitator account before it pays. Decide what the +facilitator itself does. + +## Options + +- Restore the entries the simulation reports as archived (`archivedSorobanEntries`) from the + facilitator account, then let the buyer's transaction go through. Costs XLM per restore and is + a new way to spend the facilitator's balance (spend budgets, AD-18). +- Keep refusing, but with a reason that names the archived state, so the seller or buyer can + restore it. +- Raise or split the fee cap for restore rent. Every payment's worst case grows. + +## Acceptance Criteria + +- [ ] Given a payment whose simulation restores archived entries, when `/verify` runs, then the + answer follows the chosen option and a test pins it +- [ ] Given the choice, then AD-10 (fees) or the threat model records it