diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index e1e5afc..35fefa7 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -1,5 +1,6 @@ -# The pool acceptance flow against testnet: mints a key, tops up on the first 402, then a streaming completion and two -# interleaved conversations through the OpenAI SDK. The wallet in TESTNET_WALLET_PRIVATE_KEY needs testnet LCAI. +# Testnet acceptance, from the wallet in TESTNET_WALLET_PRIVATE_KEY (it needs testnet LCAI). +# pool: mints a key, tops up on the first 402, then a streaming completion and two interleaved conversations through +# the OpenAI SDK. x402: keyless pay-per-call, from a fresh wallet that wallet funds and that sends the rest back. name: Testnet acceptance on: @@ -32,3 +33,27 @@ jobs: with: name: acceptance-pool-${{ github.run_id }} path: acceptance-pool.log + + # After pool, not beside it: both send from the same wallet, and two jobs at once would race for its nonce. + x402: + needs: pool + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: npm + - run: npm ci + - run: npm run acceptance:x402:fresh 2>&1 | tee acceptance-x402.log + env: + FUNDER_PRIVATE_KEY: ${{ secrets.TESTNET_WALLET_PRIVATE_KEY }} + LIGHTCHAIN_NETWORK: testnet + LIGHTCHAIN_MODEL: gemma4:e2b + - if: always() + uses: actions/upload-artifact@v7 + with: + name: acceptance-x402-${{ github.run_id }} + path: acceptance-x402.log diff --git a/.gitignore b/.gitignore index 849ddff..1eae0cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ dist/ +node_modules/ diff --git a/README.md b/README.md index 6d5555c..100a75f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ const lc = new Lightchain({ network: "testnet", account: privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`), payment: "x402", - maxPaymentWei: 10n ** 16n, // required: the most one call may pay (0.01 LCAI) + maxPaymentWei: 5n * 10n ** 16n, // required: the most one call may pay (0.05 LCAI; testnet charges 0.02 a job) keyless: true, // or apiKey: process.env.LIGHTCHAIN_API_KEY onPayment: (p) => console.log(`settled in ${p.hash}, ${p.amount} wei debited`), }); @@ -134,9 +134,10 @@ npm run abi # regenerate src/abi.ts from pkg/chain/abis (inside the orch npm run openapi # regenerate test/fixtures/openapi.json from consumer-api's source (inside the orchestrator's sdk/) WALLET_PRIVATE_KEY=0x... npm run acceptance # a fresh wallet: the key alone gets its 402, then account pays it; see scripts/acceptance.ts WALLET_PRIVATE_KEY=0x... LIGHTCHAIN_MODEL=gemma4:e2b npm run acceptance:x402 # keyless x402 mode; see scripts/acceptance-x402.ts +FUNDER_PRIVATE_KEY=0x... LIGHTCHAIN_MODEL=gemma4:e2b npm run acceptance:x402:fresh # the same, from a fresh wallet FUNDER_PRIVATE_KEY funds WALLET_PRIVATE_KEY=0x... npm run acceptance:pool # streaming, and two interleaved conversations in one pooled session ``` The fixtures in `test/fixtures` were recorded from a local devnet (`make devnet-full`, chain 48221) with Foundry's publicly known test account 7. The x402 tests sign the scheme's published test vectors (`test/fixtures/x402-vectors.json`, a copy of the orchestrator's `scripts/x402-vectors/vectors.json`) byte for byte, with Foundry's test account 3. Never use either key on a real network. -CI (`.github/workflows/ci.yml`) typechecks, builds and tests every pull request and every push to `main`. `acceptance.yml` runs `acceptance:pool` against testnet every night at 03:17 UTC, and on demand from the Actions tab. It pays from the wallet in the `TESTNET_WALLET_PRIVATE_KEY` secret, which needs testnet LCAI. Each run's log is kept as an artifact. +CI (`.github/workflows/ci.yml`) typechecks, builds and tests every pull request and every push to `main`. `acceptance.yml` runs `acceptance:pool`, then `acceptance:x402:fresh`, against testnet every night at 03:17 UTC, and on demand from the Actions tab. It pays from the wallet in the `TESTNET_WALLET_PRIVATE_KEY` secret, which needs testnet LCAI. `acceptance:x402:fresh` (`scripts/fresh-wallet.ts`) runs `acceptance:x402` with a wallet made for that run, which the secret's wallet funds and which sends what it has left back: `acceptance:x402` needs a wallet that never authorized a delegate. Each run's log is kept as an artifact. diff --git a/package.json b/package.json index 5420b4a..4dfbe18 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "openapi": "../consumer-api/node_modules/.bin/tsx scripts/openapi.ts", "acceptance": "node scripts/acceptance.ts", "acceptance:x402": "node scripts/acceptance-x402.ts", - "acceptance:pool": "node scripts/acceptance-pool.ts" + "acceptance:pool": "node scripts/acceptance-pool.ts", + "acceptance:x402:fresh": "node scripts/fresh-wallet.ts acceptance:x402" }, "dependencies": { "viem": "^2.47.6" diff --git a/scripts/acceptance-x402.ts b/scripts/acceptance-x402.ts index f6d0264..12d607b 100644 --- a/scripts/acceptance-x402.ts +++ b/scripts/acceptance-x402.ts @@ -10,7 +10,7 @@ // LIGHTCHAIN_CHAIN_ID and LIGHTCHAIN_JOB_REGISTRY instead of LIGHTCHAIN_NETWORK. // LIGHTCHAIN_MODEL is required: with no key there is no /v1/models to ask. // Optional: LIGHTCHAIN_DEPOSIT_WEI (default 0.1 LCAI), LIGHTCHAIN_MAX_PAYMENT_WEI -// (default 0.01 LCAI), LIGHTCHAIN_PAYER_CONCURRENCY (the server's per-payer +// (default 0.05 LCAI), LIGHTCHAIN_PAYER_CONCURRENCY (the server's per-payer // limit, default 1). import assert from 'node:assert/strict'; import OpenAI from 'openai'; @@ -35,7 +35,7 @@ const lc = new Lightchain({ network, account: privateKeyToAccount(env.WALLET_PRIVATE_KEY as Hex), payment: 'x402', - maxPaymentWei: BigInt(env.LIGHTCHAIN_MAX_PAYMENT_WEI ?? 10n ** 16n), + maxPaymentWei: BigInt(env.LIGHTCHAIN_MAX_PAYMENT_WEI ?? 5n * 10n ** 16n), keyless: true, onPayment: (p) => { payments.push(p); diff --git a/scripts/fresh-wallet.ts b/scripts/fresh-wallet.ts new file mode 100644 index 0000000..2cf698d --- /dev/null +++ b/scripts/fresh-wallet.ts @@ -0,0 +1,57 @@ +// Runs an npm script with a wallet made for this run alone, for a test that needs a wallet with no history (one +// that never authorized a delegate) and must still run every night. FUNDER_PRIVATE_KEY's wallet funds it. After the +// script, pass or fail, the fresh wallet withdraws its prepaid balance and sends all it holds back to the funder. +// +// FUNDER_PRIVATE_KEY=0x... LIGHTCHAIN_MODEL=gemma4:e2b node scripts/fresh-wallet.ts acceptance:x402 +// +// The fresh key exists only in this process and the script's environment. LIGHTCHAIN_FUND_WEI (default 0.11 LCAI: +// acceptance:x402's 0.1 deposit, plus gas) is what a run killed before the sweep leaves behind. LIGHTCHAIN_NETWORK +// picks the network (default testnet). +import { spawnSync } from 'node:child_process'; +import { createPublicClient, createWalletClient, defineChain, formatEther, type Hex, http, parseAbi } from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { networks } from '../src/index.ts'; + +const { FUNDER_PRIVATE_KEY, ...env } = process.env; +const script = process.argv[2]; +if (!script || !FUNDER_PRIVATE_KEY) throw new Error('usage: FUNDER_PRIVATE_KEY=0x... node scripts/fresh-wallet.ts '); + +const network = networks[(env.LIGHTCHAIN_NETWORK ?? 'testnet') as 'mainnet' | 'testnet']; +const chain = defineChain({ + id: network.chainId, + name: `LightChain ${network.chainId}`, + nativeCurrency: { name: 'LightChain AI', symbol: 'LCAI', decimals: 18 }, + rpcUrls: { default: { http: [network.rpcUrl] } }, +}); +const transport = http(network.rpcUrl); +const chainClient = createPublicClient({ chain, transport, pollingInterval: 1_000 }); +const funder = createWalletClient({ account: privateKeyToAccount(FUNDER_PRIVATE_KEY as Hex), chain, transport }); +const key = generatePrivateKey(); +const fresh = createWalletClient({ account: privateKeyToAccount(key), chain, transport }); +const mined = async (hash: Hex) => { + const receipt = await chainClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== 'success') throw new Error(`transaction ${hash} reverted`); + return hash; +}; + +const fund = BigInt(env.LIGHTCHAIN_FUND_WEI ?? 11n * 10n ** 16n); +const funded = await mined(await funder.sendTransaction({ to: fresh.account.address, value: fund })); +console.log(`fresh wallet ${fresh.account.address}: ${formatEther(fund)} LCAI from ${funder.account.address} in ${funded}`); + +const run = spawnSync('npm', ['run', '-s', script], { stdio: 'inherit', env: { ...env, WALLET_PRIVATE_KEY: key } }); + +const abi = parseAbi(['function prepaidBalanceOf(address) view returns (uint256)', 'function withdrawBalance(uint256)']); +const address = network.jobRegistry; +const prepaid = await chainClient.readContract({ address, abi, functionName: 'prepaidBalanceOf', args: [fresh.account.address] }); +if (prepaid > 0n) { + const hash = await mined(await fresh.writeContract({ address, abi, functionName: 'withdrawBalance', args: [prepaid] })); + console.log(`withdrew the prepaid ${formatEther(prepaid)} LCAI in ${hash}`); +} +// A legacy transfer at a fixed gas price costs exactly 21000 * gasPrice, so the rest can go back to the last wei. +const gasPrice = await chainClient.getGasPrice(); +const rest = (await chainClient.getBalance({ address: fresh.account.address })) - 21_000n * gasPrice; +if (rest > 0n) { + const hash = await mined(await fresh.sendTransaction({ to: funder.account.address, value: rest, gas: 21_000n, gasPrice })); + console.log(`returned ${formatEther(rest)} LCAI to ${funder.account.address} in ${hash}`); +} +process.exit(run.status ?? 1);