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 }} 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); +});