From aa914e9049f7ec3b770297dae7523144b61c9512 Mon Sep 17 00:00:00 2001 From: epanonymous Date: Wed, 17 Jun 2026 09:34:30 +0000 Subject: [PATCH 1/5] Add payout wallet keygen to agent registration --- README.md | 7 ++++- package-lock.json | 25 ++++++++++++++++++ package.json | 4 +++ src/auth.ts | 54 +++++++++++++++++++++++++++++++++++--- src/index.ts | 1 + src/payout-wallet.ts | 14 ++++++++++ src/types.ts | 29 ++++++++++++++++++++ test/auth.test.ts | 51 ++++++++++++++++++++++++++++++++--- test/payout-wallet.test.ts | 20 ++++++++++++++ 9 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 src/payout-wallet.ts create mode 100644 test/payout-wallet.test.ts diff --git a/README.md b/README.md index c2447d4..84c0bd0 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,14 @@ const registered = await DevaAgent.register({ description: "My first Deva agent" }); -console.log(registered.api_key); +console.log(registered.agent.api_key); +// Persist registered.payoutWallet?.secret in your own keystore/env. ``` +`DevaAgent.register` generates a local Bitplanet v3 payout wallet by default, sends only `payout_pubkey` +with the registration request, and returns the base58 secret locally as `registered.payoutWallet.secret`. +To bind an external/passkey wallet instead, pass `payoutWallet: { pubkey: "..." }` or `payout_pubkey`. + ## Client Choices Use either: diff --git a/package-lock.json b/package-lock.json index 1ad0d26..53e2a00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "@deva-me/agent-sdk", "version": "0.2.0", "license": "MIT", + "dependencies": { + "bs58": "^6.0.0", + "tweetnacl": "^1.0.3" + }, "devDependencies": { "typescript": "^5.7.3" }, @@ -15,6 +19,27 @@ "node": ">=18" } }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 5a8bf29..624eb72 100644 --- a/package.json +++ b/package.json @@ -39,5 +39,9 @@ }, "devDependencies": { "typescript": "^5.7.3" + }, + "dependencies": { + "bs58": "^6.0.0", + "tweetnacl": "^1.0.3" } } diff --git a/src/auth.ts b/src/auth.ts index 16b47a0..f6d8858 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,6 +1,52 @@ import { DevaHttpClient } from "./client.js"; import { DevaError } from "./errors.js"; -import type { RegisterAgentInput, RegisterAgentOutput } from "./types.js"; +import { generatePayoutWallet } from "./payout-wallet.js"; +import type { PayoutWallet, RegisterAgentInput, RegisterAgentOutput, RegisterAgentPayoutWallet } from "./types.js"; + +function normalizeSuppliedPayoutPubkey(value: unknown, field: string): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw new DevaError({ message: `${field} must be a base58 public key string.` }); + } + + const pubkey = value.trim(); + return pubkey.length > 0 ? pubkey : undefined; +} + +function getPayoutWalletPubkey(input: RegisterAgentPayoutWallet | undefined): string | undefined { + if (input === undefined || input === "generate" || input === false) return undefined; + if (!input || typeof input !== "object") { + throw new DevaError({ message: "payoutWallet must be \"generate\", false, or an object containing pubkey." }); + } + + return ( + normalizeSuppliedPayoutPubkey(input.pubkey, "payoutWallet.pubkey") ?? + normalizeSuppliedPayoutPubkey(input.publicKey, "payoutWallet.publicKey") + ); +} + +function prepareRegisterAgentInput(input: RegisterAgentInput): { + body: Omit; + payoutWallet?: PayoutWallet; +} { + const { payoutWallet: payoutWalletInput = "generate", ...body } = input; + const directPayoutPubkey = normalizeSuppliedPayoutPubkey(body.payout_pubkey, "payout_pubkey"); + if (directPayoutPubkey) { + return { body: { ...body, payout_pubkey: directPayoutPubkey } }; + } + + const suppliedPayoutPubkey = getPayoutWalletPubkey(payoutWalletInput); + if (suppliedPayoutPubkey) { + return { body: { ...body, payout_pubkey: suppliedPayoutPubkey } }; + } + + if (payoutWalletInput === false) { + return { body }; + } + + const payoutWallet = generatePayoutWallet(); + return { body: { ...body, payout_pubkey: payoutWallet.pubkey }, payoutWallet }; +} /** Authentication and API key lifecycle helpers. */ export class AuthResource { @@ -18,12 +64,14 @@ export class AuthResource { /** * Registers a new agent and persists the returned API key in this client. + * By default, this also generates a local v3 payout wallet and binds its pubkey. */ async registerAgent(input: RegisterAgentInput): Promise { + const { body, payoutWallet } = prepareRegisterAgentInput(input); const result = await this.client.request({ method: "POST", path: "/agents/register", - body: input, + body, requiresAuth: false }); @@ -33,6 +81,6 @@ export class AuthResource { } this.client.setApiKey(apiKey); - return result; + return payoutWallet ? { ...result, payoutWallet } : result; } } diff --git a/src/index.ts b/src/index.ts index 1e36ef1..6c66996 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export * from "./auth.js"; export * from "./client.js"; export * from "./deva-client.js"; export * from "./errors.js"; +export * from "./payout-wallet.js"; export * from "./resources/index.js"; export * from "./types.js"; export * from "./x402.js"; diff --git a/src/payout-wallet.ts b/src/payout-wallet.ts new file mode 100644 index 0000000..65485ff --- /dev/null +++ b/src/payout-wallet.ts @@ -0,0 +1,14 @@ +import bs58 from "bs58"; +import nacl from "tweetnacl"; +import type { PayoutWallet } from "./types.js"; + +/** Generates a local Bitplanet v3/Solana-compatible ed25519 payout wallet. */ +export function generatePayoutWallet(): PayoutWallet { + const keypair = nacl.sign.keyPair(); + + return { + version: "v3", + pubkey: bs58.encode(keypair.publicKey), + secret: bs58.encode(keypair.secretKey) + }; +} diff --git a/src/types.ts b/src/types.ts index a2ef8ae..7bd6fe2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,11 +50,37 @@ export interface RequestOptions { retryOn402?: boolean; } +export interface PayoutWallet { + /** Bitplanet v3 wallet format. Compatible with Solana/Agave ed25519 keys. */ + version: "v3"; + /** Base58-encoded 32-byte ed25519 public key. */ + pubkey: string; + /** Base58-encoded 64-byte ed25519 secret key. Persist this locally; it is not sent to the API. */ + secret: string; +} + +export interface SuppliedPayoutWallet { + /** Base58-encoded 32-byte ed25519 public key from an external/passkey wallet. */ + pubkey?: string; + /** Alias for callers that prefer Web Crypto-style naming. */ + publicKey?: string; +} + +export type RegisterAgentPayoutWallet = "generate" | false | SuppliedPayoutWallet; + export interface RegisterAgentInput { /** Unique agent name: 3-30 chars, alphanumeric + underscore (`^[a-zA-Z0-9_]+$`), not a reserved word. */ name: string; /** What the agent does: 10-500 characters. Required by the API. */ description: string; + /** + * Payout wallet binding for registration. + * Defaults to "generate", which creates a local v3 wallet and submits only its pubkey. + * Use false to skip SDK keygen, or pass a pubkey/publicKey for an external wallet. + */ + payoutWallet?: RegisterAgentPayoutWallet; + /** Base58-encoded payout public key sent to the API. Suppresses SDK keygen when provided. */ + payout_pubkey?: string; [key: string]: unknown; } @@ -66,6 +92,7 @@ export interface RegisteredAgent { claim_url?: string; verification_code?: string; profile_url?: string; + payout_pubkey?: string; [key: string]: unknown; } @@ -73,6 +100,8 @@ export interface RegisterAgentOutput { success?: boolean; agent: RegisteredAgent; important?: string; + /** Present only when the SDK generated a payout wallet during registration. */ + payoutWallet?: PayoutWallet; [key: string]: unknown; } diff --git a/test/auth.test.ts b/test/auth.test.ts index b2edcea..e6e3d9c 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -1,14 +1,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { DevaClient } from "../dist/esm/index.js"; +import bs58 from "bs58"; +import { DevaClient, generatePayoutWallet } from "../dist/esm/index.js"; -function registerFetch(body: unknown, status = 200): { fetch: typeof fetch; lastUrl: () => string } { +function registerFetch(body: unknown, status = 200): { fetch: typeof fetch; lastUrl: () => string; lastBody: () => unknown } { let url = ""; - const fetchImpl = (async (u: string) => { + let requestBody: unknown; + const fetchImpl = (async (u: string, init?: RequestInit) => { url = String(u); + requestBody = init?.body ? JSON.parse(String(init.body)) : undefined; return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); }) as unknown as typeof fetch; - return { fetch: fetchImpl, lastUrl: () => url }; + return { fetch: fetchImpl, lastUrl: () => url, lastBody: () => requestBody }; } test("auth.registerAgent POSTs the canonical /agents/register path", async () => { @@ -46,3 +49,43 @@ test("auth.registerAgent throws when the response carries no api_key", async () /no api_key returned/ ); }); + +test("auth.registerAgent generates and binds a payout pubkey by default", async () => { + const r = registerFetch({ + success: true, + agent: { id: "a1", name: "smoke_test", api_key: "deva_nested_key", profile_url: "http://x" }, + important: "save it" + }); + const client = new DevaClient({ apiBase: "http://localhost:8000", fetch: r.fetch }); + + const result = await client.auth.registerAgent({ name: "smoke_test", description: "a ten-plus char description" }); + const body = r.lastBody() as Record; + + assert.equal(body.payout_pubkey, result.payoutWallet?.pubkey); + assert.equal(typeof body.payout_pubkey, "string"); + assert.equal(bs58.decode(String(body.payout_pubkey)).length, 32); + assert.ok(!("payoutWallet" in body)); + assert.ok(!("secret" in body)); + assert.equal(result.payoutWallet?.version, "v3"); + assert.equal(bs58.decode(result.payoutWallet.secret).length, 64); +}); + +test("auth.registerAgent accepts a caller-supplied payout pubkey", async () => { + const suppliedWallet = generatePayoutWallet(); + const r = registerFetch({ + success: true, + agent: { id: "a1", name: "smoke_test", api_key: "deva_nested_key", profile_url: "http://x" } + }); + const client = new DevaClient({ apiBase: "http://localhost:8000", fetch: r.fetch }); + + const result = await client.auth.registerAgent({ + name: "smoke_test", + description: "a ten-plus char description", + payoutWallet: { pubkey: suppliedWallet.pubkey } + }); + const body = r.lastBody() as Record; + + assert.equal(body.payout_pubkey, suppliedWallet.pubkey); + assert.ok(!("payoutWallet" in body)); + assert.equal(result.payoutWallet, undefined); +}); diff --git a/test/payout-wallet.test.ts b/test/payout-wallet.test.ts new file mode 100644 index 0000000..46721eb --- /dev/null +++ b/test/payout-wallet.test.ts @@ -0,0 +1,20 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import bs58 from "bs58"; +import nacl from "tweetnacl"; +import { generatePayoutWallet } from "../dist/esm/index.js"; + +test("generatePayoutWallet returns a v3 ed25519 wallet with base58 key material", () => { + const wallet = generatePayoutWallet(); + const publicKey = bs58.decode(wallet.pubkey); + const secretKey = bs58.decode(wallet.secret); + + assert.equal(wallet.version, "v3"); + assert.equal(publicKey.length, 32); + assert.equal(secretKey.length, 64); + assert.deepEqual(Array.from(secretKey.slice(32)), Array.from(publicKey)); + + const message = new TextEncoder().encode("payout wallet round trip"); + const signature = nacl.sign.detached(message, secretKey); + assert.equal(nacl.sign.detached.verify(message, signature, publicKey), true); +}); From 47fda40574de7407c15c2fd0b1ff7c583a8b1375 Mon Sep 17 00:00:00 2001 From: epanonymous Date: Wed, 17 Jun 2026 09:37:58 +0000 Subject: [PATCH 2/5] Fix publish workflow dry run --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fc6d180..e4a6a80 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,9 +41,9 @@ jobs: run: npm test # `npm test` runs the full build (ESM + CJS) then the node test suite - - name: Publish (dry-run) + - name: Pack (dry-run) if: ${{ inputs.dry-run }} - run: npm publish --dry-run + run: npm pack --dry-run - name: Publish if: ${{ !inputs.dry-run }} From 7ee5ba90f0d6f135824691eb7ae1837e8171b24d Mon Sep 17 00:00:00 2001 From: epanonymous Date: Thu, 18 Jun 2026 09:20:06 +0000 Subject: [PATCH 3/5] Add local payout wallet store --- package-lock.json | 18 +++++ package.json | 1 + src/payout-wallet-store.ts | 160 +++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 src/payout-wallet-store.ts diff --git a/package-lock.json b/package-lock.json index 53e2a00..e503ca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,12 +13,23 @@ "tweetnacl": "^1.0.3" }, "devDependencies": { + "@types/node": "^18.19.130", "typescript": "^5.7.3" }, "engines": { "node": ">=18" } }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/base-x": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", @@ -53,6 +64,13 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 624eb72..84c9e63 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test": "npm run build && node --experimental-strip-types --test 'test/*.test.ts'" }, "devDependencies": { + "@types/node": "^18.19.130", "typescript": "^5.7.3" }, "dependencies": { diff --git a/src/payout-wallet-store.ts b/src/payout-wallet-store.ts new file mode 100644 index 0000000..d1d09fb --- /dev/null +++ b/src/payout-wallet-store.ts @@ -0,0 +1,160 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { PayoutWallet } from "./types.js"; + +const STORE_VERSION = 1; + +interface StoredPayoutWallet { + version: "v3"; + pubkey: string; + secret: string; + created_at: string; + bound_at?: string | null; +} + +interface PayoutWalletStoreFile { + version: typeof STORE_VERSION; + wallets: Record; +} + +const storeLocks = new Map>(); + +/** Returns the default secure local payout-wallet credential path. */ +export function defaultPayoutWalletStorePath(): string { + return join(homedir() || ".", ".deva", "payout-wallet.json"); +} + +/** Fingerprints an API base/key pair without storing the raw API key locally. */ +export function payoutWalletStoreKey(apiBase: string, apiKey: string): string { + return createHash("sha256").update(`${apiBase}\0${apiKey}`).digest("hex"); +} + +function emptyStore(): PayoutWalletStoreFile { + return { version: STORE_VERSION, wallets: {} }; +} + +function isNodeErrno(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && (error as { code?: unknown }).code === code; +} + +function normalizeStoredWallet(value: unknown): StoredPayoutWallet | undefined { + if (!value || typeof value !== "object") return undefined; + const wallet = value as Record; + if (wallet.version !== "v3" || typeof wallet.pubkey !== "string" || typeof wallet.secret !== "string") { + return undefined; + } + + return { + version: "v3", + pubkey: wallet.pubkey, + secret: wallet.secret, + created_at: typeof wallet.created_at === "string" ? wallet.created_at : new Date(0).toISOString(), + bound_at: typeof wallet.bound_at === "string" || wallet.bound_at === null ? wallet.bound_at : undefined + }; +} + +async function withStoreLock(storePath: string, fn: () => Promise): Promise { + const previous = storeLocks.get(storePath); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + + storeLocks.set(storePath, current); + if (previous) await previous.catch(() => undefined); + + try { + return await fn(); + } finally { + release(); + if (storeLocks.get(storePath) === current) { + storeLocks.delete(storePath); + } + } +} + +async function readStore(storePath: string): Promise { + let text: string; + try { + text = await readFile(storePath, "utf8"); + } catch (error) { + if (isNodeErrno(error, "ENOENT")) return emptyStore(); + throw error; + } + + if (!text.trim()) return emptyStore(); + + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object") return emptyStore(); + + const root = parsed as Record; + if (root.version !== STORE_VERSION || !root.wallets || typeof root.wallets !== "object") { + return emptyStore(); + } + + const wallets: Record = {}; + for (const [key, value] of Object.entries(root.wallets as Record)) { + const wallet = normalizeStoredWallet(value); + if (wallet) wallets[key] = wallet; + } + + return { version: STORE_VERSION, wallets }; +} + +async function writeStore(storePath: string, store: PayoutWalletStoreFile): Promise { + const dir = dirname(storePath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await chmod(dir, 0o700).catch(() => undefined); + + const tmpPath = `${storePath}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(tmpPath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); + await chmod(tmpPath, 0o600).catch(() => undefined); + await rename(tmpPath, storePath); + await chmod(storePath, 0o600).catch(() => undefined); + } catch (error) { + await rm(tmpPath, { force: true }).catch(() => undefined); + throw error; + } +} + +/** Reads a locally persisted payout wallet for an API key fingerprint. */ +export async function readStoredPayoutWallet(storePath: string, key: string): Promise { + return withStoreLock(storePath, async () => { + const store = await readStore(storePath); + const wallet = store.wallets[key]; + return wallet ? { version: "v3", pubkey: wallet.pubkey, secret: wallet.secret } : undefined; + }); +} + +/** Persists a payout wallet secret locally without storing the raw API key. */ +export async function writeStoredPayoutWallet( + storePath: string, + key: string, + wallet: PayoutWallet, + boundAt?: string | null +): Promise { + await withStoreLock(storePath, async () => { + const store = await readStore(storePath); + store.wallets[key] = { + version: "v3", + pubkey: wallet.pubkey, + secret: wallet.secret, + created_at: store.wallets[key]?.created_at ?? new Date().toISOString(), + bound_at: boundAt + }; + await writeStore(storePath, store); + }); +} + +/** Removes a generated payout wallet when another writer won the server-side bind race. */ +export async function deleteStoredPayoutWallet(storePath: string, key: string): Promise { + await withStoreLock(storePath, async () => { + const store = await readStore(storePath); + if (!(key in store.wallets)) return; + delete store.wallets[key]; + await writeStore(storePath, store); + }); +} From 3b9d906cf6b40bbf96747fd68b1175c130958f08 Mon Sep 17 00:00:00 2001 From: epanonymous Date: Thu, 18 Jun 2026 09:24:36 +0000 Subject: [PATCH 4/5] Bind payout wallet lazily on first API call --- src/auth.ts | 57 ++--------- src/client.ts | 24 +++++ src/payout-wallet-autobind.ts | 187 ++++++++++++++++++++++++++++++++++ src/types.ts | 29 +++++- 4 files changed, 246 insertions(+), 51 deletions(-) create mode 100644 src/payout-wallet-autobind.ts diff --git a/src/auth.ts b/src/auth.ts index f6d8858..bcd97d4 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,51 +1,15 @@ import { DevaHttpClient } from "./client.js"; import { DevaError } from "./errors.js"; -import { generatePayoutWallet } from "./payout-wallet.js"; -import type { PayoutWallet, RegisterAgentInput, RegisterAgentOutput, RegisterAgentPayoutWallet } from "./types.js"; - -function normalizeSuppliedPayoutPubkey(value: unknown, field: string): string | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value !== "string") { - throw new DevaError({ message: `${field} must be a base58 public key string.` }); - } - - const pubkey = value.trim(); - return pubkey.length > 0 ? pubkey : undefined; -} - -function getPayoutWalletPubkey(input: RegisterAgentPayoutWallet | undefined): string | undefined { - if (input === undefined || input === "generate" || input === false) return undefined; - if (!input || typeof input !== "object") { - throw new DevaError({ message: "payoutWallet must be \"generate\", false, or an object containing pubkey." }); - } - - return ( - normalizeSuppliedPayoutPubkey(input.pubkey, "payoutWallet.pubkey") ?? - normalizeSuppliedPayoutPubkey(input.publicKey, "payoutWallet.publicKey") - ); -} +import { getSuppliedPayoutPubkey } from "./payout-wallet-autobind.js"; +import type { RegisterAgentInput, RegisterAgentOutput, RegisterAgentPayoutWallet } from "./types.js"; function prepareRegisterAgentInput(input: RegisterAgentInput): { - body: Omit; - payoutWallet?: PayoutWallet; + body: Omit; + payoutWallet?: RegisterAgentPayoutWallet; + payoutPubkey?: string; } { - const { payoutWallet: payoutWalletInput = "generate", ...body } = input; - const directPayoutPubkey = normalizeSuppliedPayoutPubkey(body.payout_pubkey, "payout_pubkey"); - if (directPayoutPubkey) { - return { body: { ...body, payout_pubkey: directPayoutPubkey } }; - } - - const suppliedPayoutPubkey = getPayoutWalletPubkey(payoutWalletInput); - if (suppliedPayoutPubkey) { - return { body: { ...body, payout_pubkey: suppliedPayoutPubkey } }; - } - - if (payoutWalletInput === false) { - return { body }; - } - - const payoutWallet = generatePayoutWallet(); - return { body: { ...body, payout_pubkey: payoutWallet.pubkey }, payoutWallet }; + const { payoutWallet, payout_pubkey, ...body } = input; + return { body, payoutWallet, payoutPubkey: getSuppliedPayoutPubkey(payoutWallet, payout_pubkey) }; } /** Authentication and API key lifecycle helpers. */ @@ -64,10 +28,10 @@ export class AuthResource { /** * Registers a new agent and persists the returned API key in this client. - * By default, this also generates a local v3 payout wallet and binds its pubkey. + * Payout wallet binding happens lazily on the first authenticated API call. */ async registerAgent(input: RegisterAgentInput): Promise { - const { body, payoutWallet } = prepareRegisterAgentInput(input); + const { body, payoutWallet, payoutPubkey } = prepareRegisterAgentInput(input); const result = await this.client.request({ method: "POST", path: "/agents/register", @@ -81,6 +45,7 @@ export class AuthResource { } this.client.setApiKey(apiKey); - return payoutWallet ? { ...result, payoutWallet } : result; + this.client.setPayoutWalletOverride(payoutWallet, payoutPubkey); + return result; } } diff --git a/src/client.ts b/src/client.ts index f51522c..1584fa6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,5 @@ import { DevaError, X402PaymentRequiredError, classifyError, normalizeErrorEnvelope } from "./errors.js"; +import { PayoutWalletAutoBinder } from "./payout-wallet-autobind.js"; import { addX402Amounts, createWalletX402Payer, @@ -141,6 +142,7 @@ export class DevaHttpClient { private readonly x402MaxRetries: number; private readonly x402Payer?: X402Payer; private readonly x402AutoPayPolicy?: NormalizedX402AutoPayPolicy; + private readonly payoutWalletAutoBinder?: PayoutWalletAutoBinder; private x402CumulativeSpent: X402Amount = zeroX402Amount(); private readonly x402PaidChallengeKeys = new Set(); private apiKey?: string; @@ -151,6 +153,17 @@ export class DevaHttpClient { this.fetchImpl = options.fetch ?? fetch; this.apiKey = options.apiKey; + if (options.payoutWalletAutoBind !== false) { + this.payoutWalletAutoBinder = new PayoutWalletAutoBinder({ + apiBase: options.payoutWalletApiBase ?? this.baseUrl, + fetch: this.fetchImpl, + timeoutMs: this.timeoutMs, + storePath: options.payoutWalletStorePath, + payoutWallet: options.payoutWallet, + payout_pubkey: options.payout_pubkey + }); + } + this.x402Enabled = options.x402?.enabled !== false; this.x402MaxRetries = options.x402?.maxRetries ?? 1; if (options.x402?.walletAutoPay && !options.x402.autoPayPolicy) { @@ -180,10 +193,19 @@ export class DevaHttpClient { return this.apiKey; } + setPayoutWalletOverride(payoutWallet: DevaClientOptions["payoutWallet"], payoutPubkey?: string): void { + this.payoutWalletAutoBinder?.setPayoutWalletOverride(payoutWallet, payoutPubkey); + } + buildUrl(path: string, query?: RequestOptions["query"]): string { return `${this.baseUrl}${path}${toQueryString(query)}`; } + private async ensurePayoutWalletBound(options?: { requiresAuth?: boolean }): Promise { + if (options?.requiresAuth === false || !this.apiKey || !this.payoutWalletAutoBinder) return; + await this.payoutWalletAutoBinder.ensureBound(this.apiKey); + } + private async callPayer( challenge: X402Challenge, context: X402PaymentContext, @@ -256,6 +278,7 @@ export class DevaHttpClient { async request(options: RequestOptions): Promise { const url = this.buildUrl(options.path, options.query); const preparedBody = await prepareBody(options.body, options.method, url); + await this.ensurePayoutWalletBound(options); let attempt = 0; let waitMs = 300; @@ -380,6 +403,7 @@ export class DevaHttpClient { async rawFetch(path: string, init?: RequestInit, query?: RequestOptions["query"]): Promise { const url = this.buildUrl(path, query); const headers = new Headers(init?.headers ?? {}); + await this.ensurePayoutWalletBound(); if (!headers.has("authorization")) { if (!this.apiKey) { diff --git a/src/payout-wallet-autobind.ts b/src/payout-wallet-autobind.ts new file mode 100644 index 0000000..bb4eaf3 --- /dev/null +++ b/src/payout-wallet-autobind.ts @@ -0,0 +1,187 @@ +import { DevaError, classifyError, normalizeErrorEnvelope } from "./errors.js"; +import { generatePayoutWallet } from "./payout-wallet.js"; +import { + defaultPayoutWalletStorePath, + deleteStoredPayoutWallet, + payoutWalletStoreKey, + readStoredPayoutWallet, + writeStoredPayoutWallet +} from "./payout-wallet-store.js"; +import type { PayoutWallet, RegisterAgentPayoutWallet } from "./types.js"; + +const PAYOUT_WALLET_PATH = "/api/v1/agent/payout-wallet"; +const PAYOUT_WALLET_BIND_PATH = "/api/v1/agent/payout-wallet/bind"; + +interface PayoutWalletStatusResponse { + payout_pubkey?: string | null; + bound_at?: string | null; +} + +interface PayoutWalletBindResponse { + payout_pubkey?: string; + bound_at?: string | null; + already_bound?: boolean; +} + +interface PayoutWalletAutoBinderOptions { + apiBase: string; + fetch: typeof fetch; + timeoutMs: number; + storePath?: string; + payoutWallet?: RegisterAgentPayoutWallet; + payout_pubkey?: string; +} + +const boundCache = new Set(); +const bindingPromises = new Map>(); + +async function parseBody(response: Response): Promise { + const text = await response.text(); + if (!text) return {}; + + try { + return JSON.parse(text) as unknown; + } catch { + return { raw: text }; + } +} + +export function normalizeSuppliedPayoutPubkey(value: unknown, field: string): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw new DevaError({ message: `${field} must be a base58 public key string.` }); + } + + const pubkey = value.trim(); + return pubkey.length > 0 ? pubkey : undefined; +} + +export function getSuppliedPayoutPubkey( + payoutWallet: RegisterAgentPayoutWallet | undefined, + payoutPubkey?: unknown +): string | undefined { + const directPayoutPubkey = normalizeSuppliedPayoutPubkey(payoutPubkey, "payout_pubkey"); + if (directPayoutPubkey) return directPayoutPubkey; + + if (payoutWallet === undefined || payoutWallet === "generate" || payoutWallet === false) return undefined; + if (!payoutWallet || typeof payoutWallet !== "object") { + throw new DevaError({ message: "payoutWallet must be \"generate\", false, or an object containing pubkey." }); + } + + return ( + normalizeSuppliedPayoutPubkey(payoutWallet.pubkey, "payoutWallet.pubkey") ?? + normalizeSuppliedPayoutPubkey(payoutWallet.publicKey, "payoutWallet.publicKey") + ); +} + +/** Lazily provisions and binds an agent payout wallet on the first authenticated API call. */ +export class PayoutWalletAutoBinder { + private readonly apiBase: string; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + private readonly storePath: string; + private suppliedPubkey?: string; + + constructor(options: PayoutWalletAutoBinderOptions) { + this.apiBase = options.apiBase.replace(/\/$/, ""); + this.fetchImpl = options.fetch; + this.timeoutMs = options.timeoutMs; + this.storePath = options.storePath ?? defaultPayoutWalletStorePath(); + this.suppliedPubkey = getSuppliedPayoutPubkey(options.payoutWallet, options.payout_pubkey); + } + + setPayoutWalletOverride(payoutWallet: RegisterAgentPayoutWallet | undefined, payoutPubkey?: unknown): void { + const pubkey = getSuppliedPayoutPubkey(payoutWallet, payoutPubkey); + if (pubkey) { + this.suppliedPubkey = pubkey; + } + } + + async ensureBound(apiKey: string): Promise { + const key = payoutWalletStoreKey(this.apiBase, apiKey); + if (boundCache.has(key)) return; + + const existing = bindingPromises.get(key); + if (existing) { + await existing; + return; + } + + const binding = this.bind(apiKey, key) + .then(() => { + boundCache.add(key); + }) + .finally(() => { + bindingPromises.delete(key); + }); + + bindingPromises.set(key, binding); + await binding; + } + + private async fetchJson(path: string, apiKey: string, init: RequestInit = {}): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await this.fetchImpl(`${this.apiBase}${path}`, { + ...init, + headers: { + ...init.headers, + authorization: `Bearer ${apiKey}` + }, + signal: controller.signal + }); + const payload = await parseBody(response); + + if (!response.ok) { + throw classifyError(normalizeErrorEnvelope(response.status, payload)); + } + + return payload; + } catch (error) { + if (error instanceof DevaError) throw error; + throw new DevaError({ message: error instanceof Error ? error.message : "Payout wallet binding failed." }); + } finally { + clearTimeout(timeout); + } + } + + private async bind(apiKey: string, storeKey: string): Promise { + const status = (await this.fetchJson(PAYOUT_WALLET_PATH, apiKey, { method: "GET" })) as PayoutWalletStatusResponse; + if (typeof status.payout_pubkey === "string" && status.payout_pubkey.trim()) { + return; + } + + const suppliedPubkey = this.suppliedPubkey; + let wallet: PayoutWallet | undefined; + let pubkey = suppliedPubkey; + + if (!pubkey) { + wallet = await readStoredPayoutWallet(this.storePath, storeKey); + if (!wallet) { + wallet = generatePayoutWallet(); + await writeStoredPayoutWallet(this.storePath, storeKey, wallet); + } + pubkey = wallet.pubkey; + } + + const response = (await this.fetchJson(PAYOUT_WALLET_BIND_PATH, apiKey, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ payout_pubkey: pubkey }) + })) as PayoutWalletBindResponse; + + const boundPubkey = typeof response.payout_pubkey === "string" && response.payout_pubkey.trim() ? response.payout_pubkey : pubkey; + + if (wallet) { + if (boundPubkey === wallet.pubkey) { + await writeStoredPayoutWallet(this.storePath, storeKey, wallet, response.bound_at ?? null); + } else { + await deleteStoredPayoutWallet(this.storePath, storeKey); + } + } + } +} diff --git a/src/types.ts b/src/types.ts index 7bd6fe2..6a02a31 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,19 @@ export interface DevaClientOptions { apiBase?: string; timeoutMs?: number; fetch?: typeof fetch; + /** + * Enables automatic first-call payout wallet binding. Defaults to true. + * Set false only when the runtime cannot use the local credential store. + */ + payoutWalletAutoBind?: boolean; + /** Optional base URL for the payout-wallet contract. Defaults to apiBase. */ + payoutWalletApiBase?: string; + /** Optional local credential-store path. Defaults to ~/.deva/payout-wallet.json. */ + payoutWalletStorePath?: string; + /** Optional external/passkey payout wallet pubkey to bind lazily instead of generating one. */ + payoutWallet?: RegisterAgentPayoutWallet; + /** Optional direct external/passkey payout pubkey. Takes precedence over payoutWallet. */ + payout_pubkey?: string; x402?: X402Options; } @@ -59,6 +72,13 @@ export interface PayoutWallet { secret: string; } +export interface PayoutWalletSecretStore { + /** Reads a locally persisted payout wallet for this API key, if one exists. */ + get(apiKey: string): Promise; + /** Persists locally generated payout wallet secret material for this API key. */ + set(apiKey: string, wallet: PayoutWallet): Promise; +} + export interface SuppliedPayoutWallet { /** Base58-encoded 32-byte ed25519 public key from an external/passkey wallet. */ pubkey?: string; @@ -74,12 +94,11 @@ export interface RegisterAgentInput { /** What the agent does: 10-500 characters. Required by the API. */ description: string; /** - * Payout wallet binding for registration. - * Defaults to "generate", which creates a local v3 wallet and submits only its pubkey. - * Use false to skip SDK keygen, or pass a pubkey/publicKey for an external wallet. + * Optional external/passkey payout wallet pubkey to bind lazily on the first authenticated API call. + * Registration itself does not send or require a payout pubkey. */ payoutWallet?: RegisterAgentPayoutWallet; - /** Base58-encoded payout public key sent to the API. Suppresses SDK keygen when provided. */ + /** Optional base58 payout public key to bind lazily. Registration itself does not send it. */ payout_pubkey?: string; [key: string]: unknown; } @@ -100,7 +119,7 @@ export interface RegisterAgentOutput { success?: boolean; agent: RegisteredAgent; important?: string; - /** Present only when the SDK generated a payout wallet during registration. */ + /** @deprecated Wallets are now generated and stored locally during lazy first-call binding. */ payoutWallet?: PayoutWallet; [key: string]: unknown; } From d0d62b7994b2ba0b58c05c4e22b68724a09c720e Mon Sep 17 00:00:00 2001 From: epanonymous Date: Thu, 18 Jun 2026 09:30:42 +0000 Subject: [PATCH 5/5] Test lazy payout wallet autobind --- README.md | 12 +- src/types.ts | 7 - test/auth.test.ts | 14 +- test/client.test.ts | 36 +++-- test/lazy-payout-wallet.test.ts | 270 ++++++++++++++++++++++++++++++++ test/models.test.ts | 12 +- test/request-types.test.ts | 8 +- test/stream.test.ts | 12 +- test/usage.test.ts | 6 +- 9 files changed, 330 insertions(+), 47 deletions(-) create mode 100644 test/lazy-payout-wallet.test.ts diff --git a/README.md b/README.md index 84c0bd0..98929e1 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,16 @@ const registered = await DevaAgent.register({ }); console.log(registered.agent.api_key); -// Persist registered.payoutWallet?.secret in your own keystore/env. ``` -`DevaAgent.register` generates a local Bitplanet v3 payout wallet by default, sends only `payout_pubkey` -with the registration request, and returns the base58 secret locally as `registered.payoutWallet.secret`. -To bind an external/passkey wallet instead, pass `payoutWallet: { pubkey: "..." }` or `payout_pubkey`. +Registration does not require or send a payout wallet. On the first authenticated API call, the SDK checks +`GET /api/v1/agent/payout-wallet`; if no payout pubkey is bound, it generates a local v3 ed25519 wallet, +stores the secret at `~/.deva/payout-wallet.json` by default, and binds by sending only +`{ "payout_pubkey": "..." }` to `POST /api/v1/agent/payout-wallet/bind`. + +Use `payoutWalletStorePath` to customize the local credential path. To bind an external/passkey wallet +instead of generating one, pass `payoutWallet: { pubkey: "..." }`, `payoutWallet: { publicKey: "..." }`, +or `payout_pubkey`; the secret key is never sent to the API. ## Client Choices diff --git a/src/types.ts b/src/types.ts index 6a02a31..7cb6ff4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,13 +72,6 @@ export interface PayoutWallet { secret: string; } -export interface PayoutWalletSecretStore { - /** Reads a locally persisted payout wallet for this API key, if one exists. */ - get(apiKey: string): Promise; - /** Persists locally generated payout wallet secret material for this API key. */ - set(apiKey: string, wallet: PayoutWallet): Promise; -} - export interface SuppliedPayoutWallet { /** Base58-encoded 32-byte ed25519 public key from an external/passkey wallet. */ pubkey?: string; diff --git a/test/auth.test.ts b/test/auth.test.ts index e6e3d9c..64f0278 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -1,6 +1,5 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import bs58 from "bs58"; import { DevaClient, generatePayoutWallet } from "../dist/esm/index.js"; function registerFetch(body: unknown, status = 200): { fetch: typeof fetch; lastUrl: () => string; lastBody: () => unknown } { @@ -50,7 +49,7 @@ test("auth.registerAgent throws when the response carries no api_key", async () ); }); -test("auth.registerAgent generates and binds a payout pubkey by default", async () => { +test("auth.registerAgent does not send payout wallet fields by default", async () => { const r = registerFetch({ success: true, agent: { id: "a1", name: "smoke_test", api_key: "deva_nested_key", profile_url: "http://x" }, @@ -61,16 +60,13 @@ test("auth.registerAgent generates and binds a payout pubkey by default", async const result = await client.auth.registerAgent({ name: "smoke_test", description: "a ten-plus char description" }); const body = r.lastBody() as Record; - assert.equal(body.payout_pubkey, result.payoutWallet?.pubkey); - assert.equal(typeof body.payout_pubkey, "string"); - assert.equal(bs58.decode(String(body.payout_pubkey)).length, 32); + assert.ok(!("payout_pubkey" in body)); assert.ok(!("payoutWallet" in body)); assert.ok(!("secret" in body)); - assert.equal(result.payoutWallet?.version, "v3"); - assert.equal(bs58.decode(result.payoutWallet.secret).length, 64); + assert.equal(result.payoutWallet, undefined); }); -test("auth.registerAgent accepts a caller-supplied payout pubkey", async () => { +test("auth.registerAgent accepts a caller-supplied lazy payout pubkey without sending it during registration", async () => { const suppliedWallet = generatePayoutWallet(); const r = registerFetch({ success: true, @@ -85,7 +81,7 @@ test("auth.registerAgent accepts a caller-supplied payout pubkey", async () => { }); const body = r.lastBody() as Record; - assert.equal(body.payout_pubkey, suppliedWallet.pubkey); + assert.ok(!("payout_pubkey" in body)); assert.ok(!("payoutWallet" in body)); assert.equal(result.payoutWallet, undefined); }); diff --git a/test/client.test.ts b/test/client.test.ts index 5a499ad..583a944 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -8,6 +8,10 @@ import { X402PaymentRequiredError } from "../dist/esm/index.js"; +function testClient(options: ConstructorParameters[0]): DevaClient { + return new DevaClient({ payoutWalletAutoBind: false, ...options }); +} + function jsonFetch(status: number, body: unknown, headers: Record = {}): typeof fetch { return (async () => new Response(JSON.stringify(body), { @@ -192,7 +196,7 @@ function walletPolicy(overrides: Partial { - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", fetch: jsonFetch(402, { error: { type: "insufficient_quota", message: "out of credits" } }) }); @@ -203,7 +207,7 @@ test("402 insufficient_quota throws InsufficientQuotaError, not X402", async () }); test("402 with an x402 challenge still throws X402PaymentRequiredError", async () => { - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", x402: { enabled: false }, fetch: jsonFetch(402, { error: { message: "pay" } }, { @@ -219,15 +223,15 @@ test("402 with an x402 challenge still throws X402PaymentRequiredError", async ( }); test("429 throws RateLimitError; 400 throws InvalidRequestError", async () => { - const rl = new DevaClient({ apiKey: "deva_test", fetch: jsonFetch(429, { error: { type: "rate_limit_error", message: "slow" } }) }); + const rl = testClient({ apiKey: "deva_test", fetch: jsonFetch(429, { error: { type: "rate_limit_error", message: "slow" } }) }); await assert.rejects(() => rl.embeddings.create({ model: "m", input: "hi" }), (e: unknown) => e instanceof RateLimitError); - const br = new DevaClient({ apiKey: "deva_test", fetch: jsonFetch(400, { error: { type: "invalid_request_error", message: "bad" } }) }); + const br = testClient({ apiKey: "deva_test", fetch: jsonFetch(400, { error: { type: "invalid_request_error", message: "bad" } }) }); await assert.rejects(() => br.embeddings.create({ model: "m", input: "hi" }), (e: unknown) => e instanceof InvalidRequestError); }); test("402 with a challenge but a declining payer still throws X402PaymentRequiredError", async () => { - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", x402: { payer: async () => ({ paid: false }) }, fetch: jsonFetch(402, { error: { message: "pay" } }, { @@ -245,7 +249,7 @@ test("402 with a challenge but a declining payer still throws X402PaymentRequire test("wallet auto-pay requires an explicit local policy", () => { assert.throws( () => - new DevaClient({ + testClient({ apiKey: "deva_test", x402: { walletAutoPay: true } }), @@ -255,7 +259,7 @@ test("wallet auto-pay requires an explicit local policy", () => { test("wallet auto-pay pays only a policy-approved request-bound challenge", async () => { const walletRequests: unknown[] = []; - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", x402: walletPolicy(), fetch: walletAutoPayFetch(walletRequests) @@ -287,7 +291,7 @@ test("wallet auto-pay declines unapproved or unbound challenges", async () => { for (const testCase of cases) { const walletRequests: unknown[] = []; - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", x402: walletPolicy(), fetch: walletAutoPayFetch(walletRequests, testCase) @@ -303,7 +307,7 @@ test("wallet auto-pay declines unapproved or unbound challenges", async () => { test("wallet auto-pay fallback request path must include the query string", async () => { const pathnameOnlyWalletRequests: unknown[] = []; - const pathnameOnlyClient = new DevaClient({ + const pathnameOnlyClient = testClient({ apiKey: "deva_test", x402: walletPolicy(), fetch: walletAutoPayFetch(pathnameOnlyWalletRequests, { @@ -320,7 +324,7 @@ test("wallet auto-pay fallback request path must include the query string", asyn assert.equal(pathnameOnlyWalletRequests.length, 0); const queryBoundWalletRequests: unknown[] = []; - const queryBoundClient = new DevaClient({ + const queryBoundClient = testClient({ apiKey: "deva_test", x402: walletPolicy(), fetch: walletAutoPayFetch(queryBoundWalletRequests, { @@ -348,7 +352,7 @@ test("wallet auto-pay requires expiry and replay identifiers", async () => { for (const testCase of cases) { const walletRequests: unknown[] = []; - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", x402: walletPolicy(), fetch: walletAutoPayFetch(walletRequests, testCase) @@ -364,7 +368,7 @@ test("wallet auto-pay requires expiry and replay identifiers", async () => { test("wallet auto-pay enforces cumulative cap and replay guard", async () => { const cumulativeWalletRequests: unknown[] = []; - const cumulativeClient = new DevaClient({ + const cumulativeClient = testClient({ apiKey: "deva_test", x402: walletPolicy({ autoPayPolicy: { @@ -389,7 +393,7 @@ test("wallet auto-pay enforces cumulative cap and replay guard", async () => { assert.equal(cumulativeWalletRequests.length, 1); const replayWalletRequests: unknown[] = []; - const replayClient = new DevaClient({ + const replayClient = testClient({ apiKey: "deva_test", x402: walletPolicy(), fetch: walletAutoPayFetch(replayWalletRequests, { amount: "600", challengeId: "same-challenge" }) @@ -441,7 +445,7 @@ test("wallet auto-pay rolls back reservations when the payer declines", async () ); }) as unknown as typeof fetch; - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", x402: walletPolicy({ autoPayPolicy: { @@ -472,7 +476,7 @@ test("wallet auto-pay rolls back reservations when the payer declines", async () test("wallet auto-pay reserves cumulative cap before awaiting the payer", async () => { const walletRequests: unknown[] = []; - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", x402: walletPolicy({ autoPayPolicy: { @@ -499,7 +503,7 @@ test("wallet auto-pay reserves cumulative cap before awaiting the payer", async test("wallet auto-pay reserves replay keys before awaiting the payer", async () => { const walletRequests: unknown[] = []; - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", x402: walletPolicy(), fetch: concurrentWalletAutoPayFetch(walletRequests, { diff --git a/test/lazy-payout-wallet.test.ts b/test/lazy-payout-wallet.test.ts new file mode 100644 index 0000000..1dbef91 --- /dev/null +++ b/test/lazy-payout-wallet.test.ts @@ -0,0 +1,270 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import bs58 from "bs58"; +import { DevaClient, generatePayoutWallet } from "../dist/esm/index.js"; + +const API_BASE = "http://platform.test"; +const BOUND_AT = "2026-06-18T00:00:00.000Z"; +let keyCounter = 0; + +interface MockCall { + url: string; + path: string; + method: string; + authorization: string | null; + body?: unknown; +} + +interface PayoutFlowOptions { + initialPubkey?: string | null; + registrationApiKey?: string; + getDelayMs?: number; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); +} + +function parseJsonBody(body: BodyInit | null | undefined): unknown { + if (typeof body !== "string" || !body) return undefined; + return JSON.parse(body) as unknown; +} + +function uniqueApiKey(label: string): string { + keyCounter += 1; + return `deva_lazy_${label}_${process.pid}_${keyCounter}`; +} + +async function tempStorePath(label: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `deva-${label}-`)); + return join(dir, "payout-wallet.json"); +} + +async function readOnlyStoredWallet(storePath: string): Promise<{ text: string; wallet: Record }> { + const text = await readFile(storePath, "utf8"); + const parsed = JSON.parse(text) as { wallets?: Record> }; + const entries = Object.values(parsed.wallets ?? {}); + assert.equal(entries.length, 1); + return { text, wallet: entries[0] }; +} + +function createPayoutFlowFetch(options: PayoutFlowOptions = {}): { fetch: typeof fetch; calls: MockCall[] } { + const calls: MockCall[] = []; + let boundPubkey = options.initialPubkey ?? null; + + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + const method = init?.method ?? "GET"; + const body = parseJsonBody(init?.body); + const headers = new Headers(init?.headers); + + calls.push({ + url: String(input), + path: url.pathname, + method, + authorization: headers.get("authorization"), + body + }); + + if (url.pathname === "/agents/register" && method === "POST") { + return jsonResponse({ + success: true, + agent: { + id: "a1", + name: "lazy_agent", + api_key: options.registrationApiKey ?? uniqueApiKey("registered") + } + }); + } + + if (url.pathname === "/api/v1/agent/payout-wallet" && method === "GET") { + if (options.getDelayMs) { + await new Promise((resolve) => setTimeout(resolve, options.getDelayMs)); + } + + return jsonResponse({ + payout_pubkey: boundPubkey, + bound_at: boundPubkey ? BOUND_AT : null + }); + } + + if (url.pathname === "/api/v1/agent/payout-wallet/bind" && method === "POST") { + const bindBody = body as { payout_pubkey?: string }; + const requestedPubkey = bindBody.payout_pubkey; + assert.deepEqual(Object.keys(bindBody), ["payout_pubkey"]); + assert.equal(typeof requestedPubkey, "string"); + + const alreadyBound = Boolean(boundPubkey); + if (!boundPubkey) boundPubkey = requestedPubkey ?? null; + + return jsonResponse({ + payout_pubkey: boundPubkey, + bound_at: BOUND_AT, + already_bound: alreadyBound + }); + } + + return jsonResponse({ + object: "list", + data: [], + path: url.pathname, + query: url.search + }); + }) as unknown as typeof fetch; + + return { fetch: fetchImpl, calls }; +} + +function callsFor(calls: MockCall[], path: string, method?: string): MockCall[] { + return calls.filter((call) => call.path === path && (!method || call.method === method)); +} + +test("first authenticated API call generates, stores, binds, and caches a payout wallet", async () => { + const apiKey = uniqueApiKey("generated"); + const storePath = await tempStorePath("generated"); + const flow = createPayoutFlowFetch(); + const client = new DevaClient({ + apiBase: API_BASE, + apiKey, + fetch: flow.fetch, + payoutWalletStorePath: storePath + }); + + await client.models.list(); + await client.models.list({ limit: 1 }); + + const statusCalls = callsFor(flow.calls, "/api/v1/agent/payout-wallet", "GET"); + const bindCalls = callsFor(flow.calls, "/api/v1/agent/payout-wallet/bind", "POST"); + const resourceCalls = callsFor(flow.calls, "/v1/models", "GET"); + + assert.equal(statusCalls.length, 1); + assert.equal(bindCalls.length, 1); + assert.equal(resourceCalls.length, 2); + assert.equal(statusCalls[0].authorization, `Bearer ${apiKey}`); + assert.equal(bindCalls[0].authorization, `Bearer ${apiKey}`); + + const bindBody = bindCalls[0].body as { payout_pubkey: string }; + assert.deepEqual(Object.keys(bindBody), ["payout_pubkey"]); + assert.equal(bs58.decode(bindBody.payout_pubkey).length, 32); + + const stored = await readOnlyStoredWallet(storePath); + assert.equal(stored.wallet.pubkey, bindBody.payout_pubkey); + assert.equal(bs58.decode(stored.wallet.secret).length, 64); + assert.equal(stored.text.includes(apiKey), false); + assert.equal((await stat(storePath)).mode & 0o777, 0o600); +}); + +test("already-bound payout wallets skip POST, local generation, and repeat checks", async () => { + const apiKey = uniqueApiKey("already-bound"); + const storePath = await tempStorePath("already-bound"); + const existing = generatePayoutWallet().pubkey; + const flow = createPayoutFlowFetch({ initialPubkey: existing }); + const client = new DevaClient({ + apiBase: API_BASE, + apiKey, + fetch: flow.fetch, + payoutWalletStorePath: storePath + }); + + await client.models.list(); + await client.models.list({ limit: 2 }); + + assert.equal(callsFor(flow.calls, "/api/v1/agent/payout-wallet", "GET").length, 1); + assert.equal(callsFor(flow.calls, "/api/v1/agent/payout-wallet/bind", "POST").length, 0); + assert.equal(callsFor(flow.calls, "/v1/models", "GET").length, 2); + assert.equal(existsSync(storePath), false); +}); + +test("caller-supplied payout wallet binds without generating a local secret", async () => { + const apiKey = uniqueApiKey("supplied"); + const storePath = await tempStorePath("supplied"); + const supplied = generatePayoutWallet().pubkey; + const flow = createPayoutFlowFetch(); + const client = new DevaClient({ + apiBase: API_BASE, + apiKey, + fetch: flow.fetch, + payoutWalletStorePath: storePath, + payoutWallet: { publicKey: supplied } + }); + + await client.models.list(); + + const bindCalls = callsFor(flow.calls, "/api/v1/agent/payout-wallet/bind", "POST"); + assert.equal(bindCalls.length, 1); + assert.deepEqual(bindCalls[0].body, { payout_pubkey: supplied }); + assert.equal(existsSync(storePath), false); +}); + +test("registration payout override is bound lazily on the first authenticated call", async () => { + const apiKey = uniqueApiKey("registered-override"); + const storePath = await tempStorePath("registered-override"); + const supplied = generatePayoutWallet().pubkey; + const flow = createPayoutFlowFetch({ registrationApiKey: apiKey }); + const client = new DevaClient({ + apiBase: API_BASE, + fetch: flow.fetch, + payoutWalletStorePath: storePath + }); + + await client.auth.registerAgent({ + name: "lazy_agent", + description: "a ten-plus char description", + payout_pubkey: supplied + }); + await client.models.list(); + + const registrationBody = callsFor(flow.calls, "/agents/register", "POST")[0].body as Record; + assert.ok(!("payout_pubkey" in registrationBody)); + assert.ok(!("payoutWallet" in registrationBody)); + + const bindCalls = callsFor(flow.calls, "/api/v1/agent/payout-wallet/bind", "POST"); + assert.equal(bindCalls.length, 1); + assert.deepEqual(bindCalls[0].body, { payout_pubkey: supplied }); + assert.equal(existsSync(storePath), false); +}); + +test("concurrent first API calls share one payout-wallet check and bind", async () => { + const apiKey = uniqueApiKey("concurrent"); + const storePath = await tempStorePath("concurrent"); + const flow = createPayoutFlowFetch({ getDelayMs: 25 }); + const client = new DevaClient({ + apiBase: API_BASE, + apiKey, + fetch: flow.fetch, + payoutWalletStorePath: storePath + }); + + await Promise.all([client.models.list(), client.models.list({ limit: 3 }), client.profile.get()]); + + assert.equal(callsFor(flow.calls, "/api/v1/agent/payout-wallet", "GET").length, 1); + assert.equal(callsFor(flow.calls, "/api/v1/agent/payout-wallet/bind", "POST").length, 1); + assert.equal(flow.calls.filter((call) => call.path !== "/api/v1/agent/payout-wallet" && call.path !== "/api/v1/agent/payout-wallet/bind").length, 3); +}); + +test("payout wallet API base can differ from the resource API base", async () => { + const apiKey = uniqueApiKey("api-base"); + const storePath = await tempStorePath("api-base"); + const flow = createPayoutFlowFetch(); + const client = new DevaClient({ + apiBase: "http://resources.test", + payoutWalletApiBase: "http://wallets.test", + apiKey, + fetch: flow.fetch, + payoutWalletStorePath: storePath + }); + + await client.models.list(); + + const statusCall = callsFor(flow.calls, "/api/v1/agent/payout-wallet", "GET")[0]; + const resourceCall = callsFor(flow.calls, "/v1/models", "GET")[0]; + assert.equal(new URL(statusCall.url).origin, "http://wallets.test"); + assert.equal(new URL(resourceCall.url).origin, "http://resources.test"); +}); diff --git a/test/models.test.ts b/test/models.test.ts index 6347db9..ee23d5c 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -2,6 +2,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { DevaClient, DevaError } from "../dist/esm/index.js"; +function testClient(options: ConstructorParameters[0]): DevaClient { + return new DevaClient({ payoutWalletAutoBind: false, ...options }); +} + function captureFetch(body: unknown, status = 200): { fetch: typeof fetch; lastUrl: () => string } { let url = ""; const fetchImpl = (async (u: string) => { @@ -43,7 +47,7 @@ test("models.list GETs /v1/models and parses the typed envelope", async () => { pricing_version: 7, last_updated: "2026-06-08T00:00:00Z" }); - const client = new DevaClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch }); + const client = testClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch }); const list = await client.models.list(); assert.ok(r.lastUrl().endsWith("/v1/models"), r.lastUrl()); assert.equal(list.object, "list"); @@ -55,7 +59,7 @@ test("models.list GETs /v1/models and parses the typed envelope", async () => { test("models.list maps filters into the query string", async () => { const r = captureFetch({ object: "list", data: [], total_count: 0, limit: 5, offset: 0, pricing_version: 7 }); - const client = new DevaClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch }); + const client = testClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch }); await client.models.list({ featured: true, capability: "reasoning", limit: 5 }); const url = r.lastUrl(); assert.ok(url.includes("featured=true"), url); @@ -65,7 +69,7 @@ test("models.list maps filters into the query string", async () => { test("models.get GETs /v1/models/{provider}/{name}", async () => { const r = captureFetch({ ...SAMPLE_MODEL, pricing_version: 7, last_updated: null }); - const client = new DevaClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch }); + const client = testClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch }); const model = await client.models.get("openai/gpt-4o"); assert.ok(r.lastUrl().endsWith("/v1/models/openai/gpt-4o"), r.lastUrl()); assert.equal(model.id, "openai/gpt-4o"); @@ -74,7 +78,7 @@ test("models.get GETs /v1/models/{provider}/{name}", async () => { test("models.get on a 404 throws a DevaError carrying the status", async () => { const r = captureFetch({ error: { message: "Model 'x/y' not found", code: "model_not_found" } }, 404); - const client = new DevaClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch }); + const client = testClient({ apiBase: "http://localhost:8000", apiKey: "deva_test", fetch: r.fetch }); await assert.rejects( () => client.models.get("x/y"), (e: unknown) => e instanceof DevaError && e.status === 404 diff --git a/test/request-types.test.ts b/test/request-types.test.ts index e5dd2ca..40aba74 100644 --- a/test/request-types.test.ts +++ b/test/request-types.test.ts @@ -2,6 +2,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { DevaClient } from "../dist/esm/index.js"; +function testClient(options: ConstructorParameters[0]): DevaClient { + return new DevaClient({ payoutWalletAutoBind: false, ...options }); +} + test("typed request params reach the request body", async () => { let captured: any; const fetchImpl = (async (_url: string, init: RequestInit) => { @@ -12,7 +16,7 @@ test("typed request params reach the request body", async () => { }); }) as unknown as typeof fetch; - const client = new DevaClient({ apiKey: "deva_test", fetch: fetchImpl }); + const client = testClient({ apiKey: "deva_test", fetch: fetchImpl }); await client.chat.create({ model: "m", messages: [{ role: "user", content: "hi" }], @@ -36,7 +40,7 @@ test("tool_choice, tool-capable messages, and json_schema reach the request body }); }) as unknown as typeof fetch; - const client = new DevaClient({ apiKey: "deva_test", fetch: fetchImpl }); + const client = testClient({ apiKey: "deva_test", fetch: fetchImpl }); await client.chat.create({ model: "m", messages: [ diff --git a/test/stream.test.ts b/test/stream.test.ts index 425a584..6dd6b3d 100644 --- a/test/stream.test.ts +++ b/test/stream.test.ts @@ -2,13 +2,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { DevaClient, InsufficientQuotaError, InvalidRequestError } from "../dist/esm/index.js"; +function testClient(options: ConstructorParameters[0]): DevaClient { + return new DevaClient({ payoutWalletAutoBind: false, ...options }); +} + function sseFetch(sse: string, status = 200): typeof fetch { return (async () => new Response(sse, { status, headers: { "content-type": "text/event-stream" } })) as unknown as typeof fetch; } test("stream surfaces a mid-stream error frame as a typed error", async () => { - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", fetch: sseFetch('data: {"error":{"type":"insufficient_quota","message":"out of credits"}}\n\n') }); @@ -20,7 +24,7 @@ test("stream surfaces a mid-stream error frame as a typed error", async () => { }); test("stream yields chunks (incl. typed final usage) and completes cleanly on [DONE]", async () => { - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", fetch: sseFetch( 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n' + @@ -38,7 +42,7 @@ test("stream yields chunks (incl. typed final usage) and completes cleanly on [D }); test("stream throws a typed error on a non-ok initial response", async () => { - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", fetch: sseFetch('{"error":{"type":"invalid_request_error","message":"bad"}}', 400) }); @@ -50,7 +54,7 @@ test("stream throws a typed error on a non-ok initial response", async () => { }); test("stream skips malformed (non-JSON) frames without throwing", async () => { - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", fetch: sseFetch('data: {not valid json}\n\ndata: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n') }); diff --git a/test/usage.test.ts b/test/usage.test.ts index 95af05e..dcffd80 100644 --- a/test/usage.test.ts +++ b/test/usage.test.ts @@ -2,13 +2,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { DevaClient } from "../dist/esm/index.js"; +function testClient(options: ConstructorParameters[0]): DevaClient { + return new DevaClient({ payoutWalletAutoBind: false, ...options }); +} + function jsonFetch(body: unknown): typeof fetch { return (async () => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } })) as unknown as typeof fetch; } test("chat.create surfaces typed usage.cost and usage.deva", async () => { - const client = new DevaClient({ + const client = testClient({ apiKey: "deva_test", fetch: jsonFetch({ id: "c1",