Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,9 @@
},
"devDependencies": {
"typescript": "^5.7.3"
},
"dependencies": {
"bs58": "^6.0.0",
"tweetnacl": "^1.0.3"
}
}
54 changes: 51 additions & 3 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -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<RegisterAgentInput, "payoutWallet">;
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 {
Expand All @@ -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<RegisterAgentOutput> {
const { body, payoutWallet } = prepareRegisterAgentInput(input);
const result = await this.client.request<RegisterAgentOutput>({
method: "POST",
path: "/agents/register",
body: input,
body,
requiresAuth: false
});

Expand All @@ -33,6 +81,6 @@ export class AuthResource {
}

this.client.setApiKey(apiKey);
return result;
return payoutWallet ? { ...result, payoutWallet } : result;
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
14 changes: 14 additions & 0 deletions src/payout-wallet.ts
Original file line number Diff line number Diff line change
@@ -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)
};
}
29 changes: 29 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -66,13 +92,16 @@ export interface RegisteredAgent {
claim_url?: string;
verification_code?: string;
profile_url?: string;
payout_pubkey?: string;
[key: string]: unknown;
}

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

Expand Down
51 changes: 47 additions & 4 deletions test/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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<string, unknown>;

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<string, unknown>;

assert.equal(body.payout_pubkey, suppliedWallet.pubkey);
assert.ok(!("payoutWallet" in body));
assert.equal(result.payoutWallet, undefined);
});
20 changes: 20 additions & 0 deletions test/payout-wallet.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});