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
29 changes: 27 additions & 2 deletions .github/workflows/acceptance.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
dist/
node_modules/
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
});
Expand Down Expand Up @@ -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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions scripts/acceptance-x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
57 changes: 57 additions & 0 deletions scripts/fresh-wallet.ts
Original file line number Diff line number Diff line change
@@ -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 <npm script>');

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);
Loading