diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 39a982fc..02c15315 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -1,6 +1,12 @@ name: Vana SDK Pre-release on: + workflow_dispatch: + inputs: + pr_number: + description: "PR number to publish, e.g. 147" + required: true + type: number push: branches: - feature/data-portability-sdk-v1 @@ -12,7 +18,73 @@ on: - remove-subgraph jobs: + publish-pr-prerelease: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Resolve PR head + id: pr + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ inputs.pr_number }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const { owner, repo } = context.repo; + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + + if (pull.head.repo.full_name !== `${owner}/${repo}`) { + core.setFailed( + `Refusing to publish prerelease for fork PR ${prNumber}: ${pull.head.repo.full_name}`, + ); + return; + } + + core.setOutput('head_sha', pull.head.sha); + core.setOutput('dist_tag', `pr-${prNumber}`); + core.setOutput('version_suffix', `pr.${prNumber}.${pull.head.sha.slice(0, 7)}`); + + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.head_sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Set PR pre-release version + run: | + cd packages/vana-sdk + CURRENT_VERSION=$(node -p "require('./package.json').version") + npm version "${CURRENT_VERSION}-${VERSION_SUFFIX}" --no-git-tag-version --allow-same-version + env: + VERSION_SUFFIX: ${{ steps.pr.outputs.version_suffix }} + + - name: Build and publish PR pre-release + run: | + cd packages/vana-sdk + NODE_OPTIONS="--max-old-space-size=4096" npm run build + npm publish --tag "${DIST_TAG}" --access public + env: + DIST_TAG: ${{ steps.pr.outputs.dist_tag }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + publish-prerelease: + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: read @@ -39,8 +111,6 @@ jobs: - name: Verify npm token run: | - echo "Token length: ${#NODE_AUTH_TOKEN}" - echo "Token prefix: ${NODE_AUTH_TOKEN:0:4}..." npm whoami --registry https://registry.npmjs.org/ || echo "npm whoami failed - token may be invalid" npm view @opendatalabs/vana-sdk version --registry https://registry.npmjs.org/ || echo "Cannot access package" cat ~/.npmrc 2>/dev/null | sed 's/=.*/=/' || echo "No .npmrc found" diff --git a/packages/vana-sdk/package.json b/packages/vana-sdk/package.json index 08f57e3e..efd15bcc 100644 --- a/packages/vana-sdk/package.json +++ b/packages/vana-sdk/package.json @@ -87,11 +87,13 @@ "build:node": "tsup --config tsup.config.ts", "build:browser": "tsup --config tsup-browser.config.ts", "build:entries": "tsx scripts/bundle-entry-points.ts", - "build": "npm run clean && npm run build:types && npm run build:node && npm run build:browser && npm run build:entries", + "build:fix-esm": "tsx scripts/fix-esm-import-extensions.ts", + "build": "npm run clean && npm run build:types && npm run build:node && npm run build:browser && npm run build:entries && npm run build:fix-esm", "dev": "npm run build -- --watch", "lint": "eslint .", "lint:fix": "eslint . --fix", "typecheck": "tsc -p tsconfig.json", + "validate:package-imports": "npm run build && tsx scripts/validate-package-imports.ts", "validate:types": "rimraf test-dist && tsc -p tsconfig.build.json --outDir test-dist && test -f test-dist/index.node.d.ts && test -f test-dist/index.browser.d.ts && echo '✅ Type declarations can be generated' && rimraf test-dist || (echo '❌ Failed to generate type declarations' && rimraf test-dist && exit 1)", "test": "vitest", "test:verbose": "vitest --reporter=verbose", diff --git a/packages/vana-sdk/scripts/fix-esm-import-extensions.ts b/packages/vana-sdk/scripts/fix-esm-import-extensions.ts new file mode 100644 index 00000000..8f513e78 --- /dev/null +++ b/packages/vana-sdk/scripts/fix-esm-import-extensions.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env tsx +import { + existsSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; + +const distDir = resolve(process.cwd(), "dist"); +const specifierPattern = + /(\bfrom\s*["']|import\s*\(\s*["'])(\.{1,2}\/[^"']+)(["'])/g; + +function collectJsFiles(dir: string): string[] { + const files: string[] = []; + + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + const stat = statSync(path); + + if (stat.isDirectory()) { + files.push(...collectJsFiles(path)); + continue; + } + + if (path.endsWith(".js")) { + files.push(path); + } + } + + return files; +} + +function hasExplicitTarget(specifier: string): boolean { + const extension = extname(specifier); + return extension !== ""; +} + +function resolveEsmTarget(importer: string, specifier: string): string | null { + if (hasExplicitTarget(specifier)) { + return null; + } + + const basePath = resolve(dirname(importer), specifier); + if (existsSync(`${basePath}.js`)) { + return `${specifier}.js`; + } + + if (existsSync(join(basePath, "index.js"))) { + return `${specifier}/index.js`; + } + + return null; +} + +if (!existsSync(distDir)) { + throw new Error( + "dist directory does not exist. Run the build before fixing ESM imports.", + ); +} + +let filesChanged = 0; +let importsChanged = 0; + +for (const file of collectJsFiles(distDir)) { + const original = readFileSync(file, "utf8"); + let fileImportsChanged = 0; + + const updated = original.replace( + specifierPattern, + (match, prefix: string, specifier: string, suffix: string) => { + const target = resolveEsmTarget(file, specifier); + if (!target) { + return match; + } + + fileImportsChanged += 1; + return `${prefix}${target}${suffix}`; + }, + ); + + if (updated !== original) { + writeFileSync(file, updated); + filesChanged += 1; + importsChanged += fileImportsChanged; + } +} + +console.log( + `Fixed ${importsChanged} ESM import specifier${importsChanged === 1 ? "" : "s"} in ${filesChanged} file${filesChanged === 1 ? "" : "s"} under ${relative(process.cwd(), distDir)}`, +); diff --git a/packages/vana-sdk/scripts/validate-package-imports.ts b/packages/vana-sdk/scripts/validate-package-imports.ts new file mode 100644 index 00000000..971dd392 --- /dev/null +++ b/packages/vana-sdk/scripts/validate-package-imports.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env tsx +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; + +const imports = [ + "@opendatalabs/vana-sdk/protocol/personal-server-registration", + "@opendatalabs/vana-sdk/protocol/personal-server-lite-owner-binding", + "@opendatalabs/vana-sdk/account/personal-server-registration", + "@opendatalabs/vana-sdk/account/personal-server-lite-owner-binding", + "@opendatalabs/vana-sdk/browser", +]; + +function run(command: string, args: string[], cwd: string): string { + return execFileSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +const tempDir = mkdtempSync(join(tmpdir(), "vana-sdk-package-imports-")); + +try { + const packOutput = run( + "npm", + ["pack", "--pack-destination", tempDir], + process.cwd(), + ); + const tarball = join(tempDir, packOutput.trim().split(/\r?\n/).at(-1) ?? ""); + const consumerDir = join(tempDir, "consumer"); + + run("npm", ["init", "-y"], tempDir); + run( + "npm", + ["install", "--prefix", consumerDir, "--no-audit", "--no-fund", tarball], + tempDir, + ); + + for (const specifier of imports) { + run( + "node", + [ + "--input-type=module", + "-e", + `await import(${JSON.stringify(specifier)})`, + ], + consumerDir, + ); + console.log(`✓ ${specifier}`); + } +} finally { + rmSync(tempDir, { recursive: true, force: true }); +} diff --git a/packages/vana-sdk/src/account/personal-server-lite-owner-binding.test.ts b/packages/vana-sdk/src/account/personal-server-lite-owner-binding.test.ts new file mode 100644 index 00000000..529b3861 --- /dev/null +++ b/packages/vana-sdk/src/account/personal-server-lite-owner-binding.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Address } from "viem"; +import { signPersonalServerLiteOwnerBindingWithAccountClient } from "./personal-server-lite-owner-binding"; +import type { AccountPersonalServerLiteOwnerBindingError } from "./personal-server-lite-owner-binding"; + +const OWNER_ADDRESS = "0x2ab394e4be7c43ac360d226a31e1c90bc01aafa1" as Address; +const LOWER_OWNER_ADDRESS = "0x2ab394e4be7c43ac360d226a31e1c90bc01aafa1"; +const SIGNATURE = `0x${"cc".repeat(65)}` as const; + +describe("Account PS Lite owner-binding integration", () => { + it("gets the Account wallet and signs the PS Lite owner-binding message", async () => { + const client = { + getAddress: vi.fn().mockResolvedValue(OWNER_ADDRESS), + signMessage: vi.fn().mockResolvedValue(SIGNATURE), + }; + + const result = await signPersonalServerLiteOwnerBindingWithAccountClient({ + client, + }); + + const message = `vana.account.v1:ps-lite-owner:${LOWER_OWNER_ADDRESS}`; + expect(client.signMessage).toHaveBeenCalledWith({ message }); + expect(result).toEqual({ + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + message, + purpose: "ps-lite-owner", + }); + }); + + it("uses a typed Account error when Account has no wallet", async () => { + const client = { + getAddress: vi.fn().mockResolvedValue(null), + signMessage: vi.fn(), + }; + + await expect( + signPersonalServerLiteOwnerBindingWithAccountClient({ client }), + ).rejects.toMatchObject({ + name: "AccountPersonalServerLiteOwnerBindingError", + code: "account_address_required", + } satisfies Partial); + }); + + it("preserves wallet rejection codes", async () => { + const rejection = Object.assign(new Error("User rejected request"), { + code: 4001, + }); + const client = { + getAddress: vi.fn().mockResolvedValue(OWNER_ADDRESS), + signMessage: vi.fn().mockRejectedValue(rejection), + }; + + await expect( + signPersonalServerLiteOwnerBindingWithAccountClient({ client }), + ).rejects.toMatchObject({ + name: "AccountPersonalServerLiteOwnerBindingError", + code: 4001, + message: "User rejected request", + details: rejection, + } satisfies Partial); + }); +}); diff --git a/packages/vana-sdk/src/account/personal-server-lite-owner-binding.ts b/packages/vana-sdk/src/account/personal-server-lite-owner-binding.ts new file mode 100644 index 00000000..a6bbf544 --- /dev/null +++ b/packages/vana-sdk/src/account/personal-server-lite-owner-binding.ts @@ -0,0 +1,99 @@ +/** + * Optional first-party Account integration for PS Lite owner binding. + * + * The protocol helper lives in `protocol/personal-server-lite-owner-binding`. + * This module adapts any Account-style client exposing `getAddress` and + * `signMessage` to the SDK owner-binding signature shape. + * + * @category Account + */ + +import type { Address, Hex } from "viem"; +import { + buildPersonalServerLiteOwnerBindingMessage, + PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + type PersonalServerLiteOwnerBindingSignature, +} from "../protocol/personal-server-lite-owner-binding"; + +export interface AccountPersonalServerLiteOwnerBindingClient { + getAddress(): Promise
| Address | null; + signMessage(input: { + message: ReturnType; + }): Promise | Hex; +} + +export interface SignPersonalServerLiteOwnerBindingWithAccountClientConfig { + client: AccountPersonalServerLiteOwnerBindingClient; +} + +export class AccountPersonalServerLiteOwnerBindingError extends Error { + code?: number | string; + details?: unknown; + + constructor(input: { + message: string; + code?: number | string; + details?: unknown; + }) { + super(input.message); + this.name = "AccountPersonalServerLiteOwnerBindingError"; + this.code = input.code; + this.details = input.details; + } +} + +export async function signPersonalServerLiteOwnerBindingWithAccountClient( + config: SignPersonalServerLiteOwnerBindingWithAccountClientConfig, +): Promise { + let address: Address | null; + try { + address = await config.client.getAddress(); + } catch (error) { + throw accountOwnerBindingError(error); + } + + if (!address) { + throw new AccountPersonalServerLiteOwnerBindingError({ + message: "Account did not return a wallet address", + code: "account_address_required", + }); + } + + const message = buildPersonalServerLiteOwnerBindingMessage(address); + let signature: Hex; + try { + signature = await config.client.signMessage({ message }); + } catch (error) { + throw accountOwnerBindingError(error); + } + + return { + signature, + signerAddress: address, + message, + purpose: PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + }; +} + +function accountOwnerBindingError( + error: unknown, +): AccountPersonalServerLiteOwnerBindingError { + if (error instanceof AccountPersonalServerLiteOwnerBindingError) { + return error; + } + + const rpcError = error as + | { code?: number | string; message?: string } + | undefined; + const code = rpcError?.code; + const message = + typeof rpcError?.message === "string" && rpcError.message.length > 0 + ? rpcError.message + : "Account PS Lite owner-binding signature failed"; + + return new AccountPersonalServerLiteOwnerBindingError({ + message, + code, + details: error, + }); +} diff --git a/packages/vana-sdk/src/account/personal-server-registration.test.ts b/packages/vana-sdk/src/account/personal-server-registration.test.ts new file mode 100644 index 00000000..ab8875a6 --- /dev/null +++ b/packages/vana-sdk/src/account/personal-server-registration.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + buildPersonalServerRegistrationTypedData, + type PersonalServerRegistrationSigner, +} from "../protocol/personal-server-registration"; +import type { AccountPersonalServerRegistrationError } from "./personal-server-registration"; +import { + ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + signPersonalServerRegistrationWithAccount, +} from "./personal-server-registration"; + +const ACCOUNT_ORIGIN = "https://account.app-dev.example"; +const OWNER_ADDRESS = "0x1111111111111111111111111111111111111111"; +const SERVER_ADDRESS = "0x2222222222222222222222222222222222222222"; +const SERVER_PUBLIC_KEY = "did:key:z6MkiPersonalServerPublicKey"; +const SERVER_URL = "https://ps.example.com"; +const SIGNATURE = `0x${"aa".repeat(65)}` as const; + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +describe("Account Personal Server registration integration", () => { + it("returns the SDK signed result when Account silently signs", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "signed", + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + }), + ); + + const result = await signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ); + + expect(fetchImpl).toHaveBeenCalledWith( + new URL( + "/api/v1/intents/personal-server-registration/sign", + `${ACCOUNT_ORIGIN}/`, + ), + expect.objectContaining({ + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + }), + ); + expect(result).toEqual({ + status: "signed", + result: { + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + typedData: buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + }, + }); + }); + + it("returns confirmation-required typed data without hiding fallback semantics", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + config: { + chainId: 1480, + contracts: { + dataRegistry: PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + dataPortabilityPermissions: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + dataPortabilityServer: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + dataPortabilityGrantees: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + }, + }, + }); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "confirmation_required", + signerAddress: OWNER_ADDRESS, + typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).resolves.toEqual({ + status: "confirmation_required", + signerAddress: OWNER_ADDRESS, + typedData, + }); + }); + + it("passes optional domain overrides through to Account", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "signed", + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + }), + ); + + await signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + chainId: 31337, + verifyingContract: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + }, + ); + + expect(fetchImpl).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + body: JSON.stringify({ + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + chainId: 31337, + verifyingContract: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + }), + }), + ); + }); + + it("normalizes the current experimental silent-sign response shape", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "fallback_required", + signer: { address: OWNER_ADDRESS }, + typed_data: typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { + accountOrigin: ACCOUNT_ORIGIN, + endpointPath: "/api/experimental/silent-sign", + fetchImpl, + }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).resolves.toEqual({ + status: "confirmation_required", + signerAddress: OWNER_ADDRESS, + typedData, + }); + }); + + it("normalizes experimental signed responses", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "signed", + signature: SIGNATURE, + signer: { address: OWNER_ADDRESS }, + typed_data: typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { + accountOrigin: ACCOUNT_ORIGIN, + endpointPath: "/api/experimental/silent-sign", + fetchImpl, + }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).resolves.toEqual({ + status: "signed", + result: { + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + typedData, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + }, + }); + }); + + it("rejects signed typed data that does not match the Account signer", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: "0x3333333333333333333333333333333333333333", + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "signed", + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).rejects.toThrow( + "Account typedData ownerAddress must match the expected signer address", + ); + }); + + it("rejects confirmation typed data that does not match the requested server", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: "0x3333333333333333333333333333333333333333", + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "confirmation_required", + signerAddress: OWNER_ADDRESS, + typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).rejects.toThrow( + "Account typedData serverAddress must match the requested serverAddress", + ); + }); + + it("uses a fallback signer for returned confirmation typed data when provided", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fallbackSigner: PersonalServerRegistrationSigner = { + address: OWNER_ADDRESS, + signTypedData: vi.fn().mockResolvedValue(SIGNATURE), + }; + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "confirmation_required", + typedData, + }), + ); + + const result = await signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl, fallbackSigner }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ); + + expect(fallbackSigner.signTypedData).toHaveBeenCalledWith(typedData); + expect(result).toEqual({ + status: "fallback_signed", + accountStatus: "confirmation_required", + result: { + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + typedData, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + }, + }); + }); + + it("rejects fallback typed data before signing when the owner does not match the fallback signer", async () => { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: "0x3333333333333333333333333333333333333333", + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + const fallbackSigner: PersonalServerRegistrationSigner = { + address: OWNER_ADDRESS, + signTypedData: vi.fn().mockResolvedValue(SIGNATURE), + }; + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + status: "confirmation_required", + typedData, + }), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl, fallbackSigner }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).rejects.toThrow( + "Account typedData ownerAddress must match the expected signer address", + ); + expect(fallbackSigner.signTypedData).not.toHaveBeenCalled(); + }); + + it("preserves structured Account error details", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue( + jsonResponse( + { error: { code: "account_session_required" } }, + { status: 401 }, + ), + ); + + await expect( + signPersonalServerRegistrationWithAccount( + { accountOrigin: ACCOUNT_ORIGIN, fetchImpl }, + { + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + ), + ).rejects.toMatchObject({ + name: "AccountPersonalServerRegistrationError", + status: 401, + code: "account_session_required", + message: + "Account PS registration signing failed: account_session_required", + details: { error: { code: "account_session_required" } }, + } satisfies Partial); + }); +}); diff --git a/packages/vana-sdk/src/account/personal-server-registration.ts b/packages/vana-sdk/src/account/personal-server-registration.ts new file mode 100644 index 00000000..ff5e1783 --- /dev/null +++ b/packages/vana-sdk/src/account/personal-server-registration.ts @@ -0,0 +1,412 @@ +/** + * Optional first-party Account integration for Personal Server registration. + * + * The protocol helper lives in `protocol/personal-server-registration`. + * This module is only for callers that want to use an Account deployment's + * constrained silent-sign endpoint. + * + * @category Account + */ + +import { isAddress, type Address, type Hex } from "viem"; +import { + buildPersonalServerRegistrationTypedData, + type BuildPersonalServerRegistrationTypedDataInput, + type PersonalServerRegistrationSignature, + type PersonalServerRegistrationSigner, + type PersonalServerRegistrationTypedData, + personalServerRegistrationDomain, +} from "../protocol/personal-server-registration"; +import { SERVER_REGISTRATION_TYPES } from "../protocol/eip712"; + +export const ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT = + "personal_server.server_registration.v1" as const; + +export type AccountPersonalServerRegistrationIntent = + typeof ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT; + +export type AccountPersonalServerRegistrationStatus = + | "signed" + | "confirmation_required" + | "fallback_required"; + +export interface AccountPersonalServerRegistrationRequest extends Omit< + BuildPersonalServerRegistrationTypedDataInput, + "ownerAddress" +> {} + +export interface AccountPersonalServerRegistrationConfig { + /** + * Origin for the Account deployment to call, e.g. an app-dev Account origin. + * No production origin is assumed by the SDK. + */ + accountOrigin: string; + /** + * Path for Account's constrained PS registration silent-sign endpoint. + */ + endpointPath?: string; + /** + * Optional fetch implementation for tests and non-default runtimes. + */ + fetchImpl?: typeof fetch; + /** + * Optional signer used when Account says user confirmation is required and + * returns typed data for the caller to sign interactively. + */ + fallbackSigner?: PersonalServerRegistrationSigner; +} + +export type AccountPersonalServerRegistrationSignature = + PersonalServerRegistrationSignature & { + intent: AccountPersonalServerRegistrationIntent; + }; + +export interface AccountSignedPersonalServerRegistration { + status: "signed"; + result: AccountPersonalServerRegistrationSignature; +} + +export interface AccountConfirmationRequiredPersonalServerRegistration { + status: "confirmation_required"; + typedData: PersonalServerRegistrationTypedData; + signerAddress?: Address; +} + +export interface AccountFallbackSignedPersonalServerRegistration { + status: "fallback_signed"; + accountStatus: "confirmation_required"; + result: AccountPersonalServerRegistrationSignature; +} + +export type AccountPersonalServerRegistrationResult = + | AccountSignedPersonalServerRegistration + | AccountConfirmationRequiredPersonalServerRegistration + | AccountFallbackSignedPersonalServerRegistration; + +export class AccountPersonalServerRegistrationError extends Error { + status: number; + code?: string; + details?: unknown; + + constructor(input: { + status: number; + message: string; + code?: string; + details?: unknown; + }) { + super(input.message); + this.name = "AccountPersonalServerRegistrationError"; + this.status = input.status; + this.code = input.code; + this.details = input.details; + } +} + +interface AccountSilentSignResponse { + status: AccountPersonalServerRegistrationStatus; + signature?: Hex; + signerAddress?: Address; + signer?: { address?: Address }; + typedData?: PersonalServerRegistrationTypedData; + typed_data?: PersonalServerRegistrationTypedData; + error?: unknown; +} + +// Account-owned route policy. Protocol signing primitives deliberately do not +// define Account intent names or API paths. +const DEFAULT_ACCOUNT_PS_REGISTRATION_PATH = + "/api/v1/intents/personal-server-registration/sign"; + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +function assertAddress(value: Address, name: string): void { + if (!isAddress(value)) { + throw new Error(`${name} must be a valid EVM address`); + } +} + +async function parseAccountResponse( + response: Response, +): Promise { + const body = (await response.json().catch(() => undefined)) as unknown; + + if (!response.ok) { + throw new AccountPersonalServerRegistrationError({ + status: response.status, + code: accountErrorCode(body), + message: accountErrorMessage(response.status, body), + details: body, + }); + } + + return body as AccountSilentSignResponse; +} + +function accountErrorMessage(status: number, body: unknown): string { + const nestedMessage = nestedAccountErrorField(body, "message"); + if (nestedMessage) { + return nestedMessage; + } + + if (isRecord(body) && typeof body.message === "string") { + return body.message; + } + + const code = accountErrorCode(body); + if (code) { + return `Account PS registration signing failed: ${code}`; + } + + return `Account PS registration signing failed: ${status}`; +} + +function accountErrorCode(body: unknown): string | undefined { + const nestedCode = nestedAccountErrorField(body, "code"); + if (nestedCode) { + return nestedCode; + } + + if (isRecord(body)) { + if (typeof body.code === "string") { + return body.code; + } + if (typeof body.error === "string") { + return body.error; + } + } + + return undefined; +} + +function nestedAccountErrorField( + body: unknown, + field: "code" | "message", +): string | undefined { + if (!isRecord(body) || !isRecord(body.error)) { + return undefined; + } + + const value = body.error[field]; + return typeof value === "string" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function normalizeAccountResponse( + response: AccountSilentSignResponse, +): AccountSilentSignResponse { + return { + ...response, + status: + response.status === "fallback_required" + ? "confirmation_required" + : response.status, + signerAddress: response.signerAddress ?? response.signer?.address, + typedData: response.typedData ?? response.typed_data, + }; +} + +function buildSignedResult( + response: Required< + Pick + > & + Pick, + request: AccountPersonalServerRegistrationRequest, +): AccountPersonalServerRegistrationSignature { + assertAddress(response.signerAddress, "signerAddress"); + if (response.typedData) { + assertTypedDataMatchesRequest( + response.typedData, + request, + response.signerAddress, + ); + } + + return { + signature: response.signature, + signerAddress: response.signerAddress, + typedData: + response.typedData ?? + buildPersonalServerRegistrationTypedData({ + ownerAddress: response.signerAddress, + ...request, + }), + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + }; +} + +function assertTypedDataMatchesRequest( + typedData: PersonalServerRegistrationTypedData, + request: AccountPersonalServerRegistrationRequest, + expectedSignerAddress?: Address, +): void { + assertAddress( + typedData.message.ownerAddress, + "typedData.message.ownerAddress", + ); + assertAddress( + typedData.message.serverAddress, + "typedData.message.serverAddress", + ); + + if ( + expectedSignerAddress && + !sameAddress(typedData.message.ownerAddress, expectedSignerAddress) + ) { + throw new Error( + "Account typedData ownerAddress must match the expected signer address", + ); + } + + if (!sameAddress(typedData.message.serverAddress, request.serverAddress)) { + throw new Error( + "Account typedData serverAddress must match the requested serverAddress", + ); + } + + if (typedData.message.publicKey !== request.serverPublicKey) { + throw new Error( + "Account typedData publicKey must match the requested serverPublicKey", + ); + } + + if (typedData.message.serverUrl !== request.serverUrl) { + throw new Error( + "Account typedData serverUrl must match the requested serverUrl", + ); + } + + if (typedData.primaryType !== "ServerRegistration") { + throw new Error("Account typedData primaryType must be ServerRegistration"); + } + + if ( + JSON.stringify(typedData.types) !== + JSON.stringify(SERVER_REGISTRATION_TYPES) + ) { + throw new Error("Account typedData types must be ServerRegistration types"); + } + + const expectedDomain = personalServerRegistrationDomain({ + config: request.config, + chainId: request.chainId, + verifyingContract: request.verifyingContract, + }); + if (!domainsEqual(typedData.domain, expectedDomain)) { + throw new Error("Account typedData domain must match the requested domain"); + } +} + +function sameAddress(a: Address, b: Address): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +function domainsEqual( + a: PersonalServerRegistrationTypedData["domain"], + b: PersonalServerRegistrationTypedData["domain"], +): boolean { + if (!a || !b) { + return false; + } + + return ( + a.name === b.name && + a.version === b.version && + Number(a.chainId) === Number(b.chainId) && + String(a.verifyingContract ?? "").toLowerCase() === + String(b.verifyingContract ?? "").toLowerCase() && + a.salt === b.salt + ); +} + +export async function signPersonalServerRegistrationWithAccount( + config: AccountPersonalServerRegistrationConfig, + request: AccountPersonalServerRegistrationRequest, +): Promise { + assertAddress(request.serverAddress, "serverAddress"); + + const fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis); + const endpoint = new URL( + config.endpointPath ?? DEFAULT_ACCOUNT_PS_REGISTRATION_PATH, + `${trimTrailingSlash(config.accountOrigin)}/`, + ); + + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + serverAddress: request.serverAddress, + serverPublicKey: request.serverPublicKey, + serverUrl: request.serverUrl, + config: request.config, + chainId: request.chainId, + verifyingContract: request.verifyingContract, + }), + }); + const body = normalizeAccountResponse(await parseAccountResponse(response)); + + if (body.status === "signed") { + if (!body.signature || !body.signerAddress) { + throw new Error( + "Account signed response must include signature and signerAddress", + ); + } + + return { + status: "signed", + result: buildSignedResult( + { + signature: body.signature, + signerAddress: body.signerAddress, + typedData: body.typedData, + }, + request, + ), + }; + } + + if (body.status === "confirmation_required") { + if (!body.typedData) { + throw new Error( + "Account confirmation_required response must include typedData", + ); + } + assertTypedDataMatchesRequest(body.typedData, request, body.signerAddress); + + if (!config.fallbackSigner) { + return { + status: "confirmation_required", + typedData: body.typedData, + signerAddress: body.signerAddress, + }; + } + + assertTypedDataMatchesRequest( + body.typedData, + request, + config.fallbackSigner.address, + ); + const signature = await config.fallbackSigner.signTypedData(body.typedData); + + return { + status: "fallback_signed", + accountStatus: "confirmation_required", + result: { + signature, + signerAddress: config.fallbackSigner.address, + typedData: body.typedData, + intent: ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + }, + }; + } + + throw new Error( + `Unsupported Account PS registration signing status: ${String(body.status)}`, + ); +} diff --git a/packages/vana-sdk/src/index.browser.ts b/packages/vana-sdk/src/index.browser.ts index bef21112..b7676e30 100644 --- a/packages/vana-sdk/src/index.browser.ts +++ b/packages/vana-sdk/src/index.browser.ts @@ -160,6 +160,59 @@ export { type ServerRegistrationMessage, type BuilderRegistrationMessage, } from "./protocol/eip712"; +export { + PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + personalServerRegistrationDomain, + createViemPersonalServerRegistrationSigner, + buildPersonalServerRegistrationTypedData, + buildPersonalServerRegistrationSignature, + registerPersonalServerSignature, + type PersonalServerRegistrationTypedData, + type PersonalServerRegistrationSigner, + type PersonalServerRegistrationDomainInput, + type ViemPersonalServerRegistrationWalletClient, + type ViemPersonalServerRegistrationSignerSource, + type BuildPersonalServerRegistrationTypedDataInput, + type BuildPersonalServerRegistrationSignatureInput, + type PersonalServerRegistrationSignature, +} from "./protocol/personal-server-registration"; +export { + PERSONAL_SERVER_LITE_OWNER_BINDING_VERSION, + PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX, + buildPersonalServerLiteOwnerBindingMessage, + createViemPersonalServerLiteOwnerBindingSigner, + buildPersonalServerLiteOwnerBindingSignature, + signPersonalServerLiteOwnerBinding, + type PersonalServerLiteOwnerBindingPurpose, + type PersonalServerLiteOwnerBindingMessage, + type PersonalServerLiteOwnerBindingSigner, + type ViemPersonalServerLiteOwnerBindingWalletClient, + type ViemPersonalServerLiteOwnerBindingSignerSource, + type BuildPersonalServerLiteOwnerBindingSignatureInput, + type PersonalServerLiteOwnerBindingSignature, +} from "./protocol/personal-server-lite-owner-binding"; +export { + ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + AccountPersonalServerRegistrationError, + signPersonalServerRegistrationWithAccount, + type AccountPersonalServerRegistrationIntent, + type AccountPersonalServerRegistrationSignature, + type AccountPersonalServerRegistrationStatus, + type AccountPersonalServerRegistrationRequest, + type AccountPersonalServerRegistrationConfig, + type AccountSignedPersonalServerRegistration, + type AccountConfirmationRequiredPersonalServerRegistration, + type AccountFallbackSignedPersonalServerRegistration, + type AccountPersonalServerRegistrationResult, +} from "./account/personal-server-registration"; +export { + AccountPersonalServerLiteOwnerBindingError, + signPersonalServerLiteOwnerBindingWithAccountClient, + type AccountPersonalServerLiteOwnerBindingClient, + type SignPersonalServerLiteOwnerBindingWithAccountClientConfig, +} from "./account/personal-server-lite-owner-binding"; export { isDataPortabilityGatewayConfig, parseGrantRegistrationPayload, diff --git a/packages/vana-sdk/src/index.node.ts b/packages/vana-sdk/src/index.node.ts index 69fe6a5d..72ddf2c6 100644 --- a/packages/vana-sdk/src/index.node.ts +++ b/packages/vana-sdk/src/index.node.ts @@ -160,6 +160,59 @@ export { type ServerRegistrationMessage, type BuilderRegistrationMessage, } from "./protocol/eip712"; +export { + PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + personalServerRegistrationDomain, + createViemPersonalServerRegistrationSigner, + buildPersonalServerRegistrationTypedData, + buildPersonalServerRegistrationSignature, + registerPersonalServerSignature, + type PersonalServerRegistrationTypedData, + type PersonalServerRegistrationSigner, + type PersonalServerRegistrationDomainInput, + type ViemPersonalServerRegistrationWalletClient, + type ViemPersonalServerRegistrationSignerSource, + type BuildPersonalServerRegistrationTypedDataInput, + type BuildPersonalServerRegistrationSignatureInput, + type PersonalServerRegistrationSignature, +} from "./protocol/personal-server-registration"; +export { + PERSONAL_SERVER_LITE_OWNER_BINDING_VERSION, + PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX, + buildPersonalServerLiteOwnerBindingMessage, + createViemPersonalServerLiteOwnerBindingSigner, + buildPersonalServerLiteOwnerBindingSignature, + signPersonalServerLiteOwnerBinding, + type PersonalServerLiteOwnerBindingPurpose, + type PersonalServerLiteOwnerBindingMessage, + type PersonalServerLiteOwnerBindingSigner, + type ViemPersonalServerLiteOwnerBindingWalletClient, + type ViemPersonalServerLiteOwnerBindingSignerSource, + type BuildPersonalServerLiteOwnerBindingSignatureInput, + type PersonalServerLiteOwnerBindingSignature, +} from "./protocol/personal-server-lite-owner-binding"; +export { + ACCOUNT_PERSONAL_SERVER_REGISTRATION_INTENT, + AccountPersonalServerRegistrationError, + signPersonalServerRegistrationWithAccount, + type AccountPersonalServerRegistrationIntent, + type AccountPersonalServerRegistrationSignature, + type AccountPersonalServerRegistrationStatus, + type AccountPersonalServerRegistrationRequest, + type AccountPersonalServerRegistrationConfig, + type AccountSignedPersonalServerRegistration, + type AccountConfirmationRequiredPersonalServerRegistration, + type AccountFallbackSignedPersonalServerRegistration, + type AccountPersonalServerRegistrationResult, +} from "./account/personal-server-registration"; +export { + AccountPersonalServerLiteOwnerBindingError, + signPersonalServerLiteOwnerBindingWithAccountClient, + type AccountPersonalServerLiteOwnerBindingClient, + type SignPersonalServerLiteOwnerBindingWithAccountClientConfig, +} from "./account/personal-server-lite-owner-binding"; export { isDataPortabilityGatewayConfig, parseGrantRegistrationPayload, diff --git a/packages/vana-sdk/src/protocol/eip712.ts b/packages/vana-sdk/src/protocol/eip712.ts index f1c38f02..e8c6a12c 100644 --- a/packages/vana-sdk/src/protocol/eip712.ts +++ b/packages/vana-sdk/src/protocol/eip712.ts @@ -144,7 +144,7 @@ export interface GrantRevocationMessage { export interface ServerRegistrationMessage { ownerAddress: `0x${string}`; serverAddress: `0x${string}`; - publicKey: `0x${string}`; + publicKey: string; serverUrl: string; } diff --git a/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.test.ts b/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.test.ts new file mode 100644 index 00000000..8c19b328 --- /dev/null +++ b/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Address } from "viem"; +import { + buildPersonalServerLiteOwnerBindingMessage, + buildPersonalServerLiteOwnerBindingSignature, + createViemPersonalServerLiteOwnerBindingSigner, + PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX, + PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, +} from "./personal-server-lite-owner-binding"; + +const OWNER_ADDRESS = "0x2ab394e4be7c43ac360d226a31e1c90bc01aafa1" as Address; +const LOWER_OWNER_ADDRESS = "0x2ab394e4be7c43ac360d226a31e1c90bc01aafa1"; +const SIGNATURE = `0x${"bb".repeat(65)}` as const; + +describe("PS Lite owner-binding helpers", () => { + it("builds the stable owner-binding message expected by PS Lite", () => { + expect(PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX).toBe( + "vana.account.v1:ps-lite-owner:", + ); + expect(buildPersonalServerLiteOwnerBindingMessage(OWNER_ADDRESS)).toBe( + `vana.account.v1:ps-lite-owner:${LOWER_OWNER_ADDRESS}`, + ); + }); + + it("rejects invalid owner addresses", () => { + expect(() => + buildPersonalServerLiteOwnerBindingMessage("not-an-address" as never), + ).toThrow("ownerAddress must be a valid EVM address"); + }); + + it("signs the owner-binding message with a generic signer", async () => { + const signer = { + address: OWNER_ADDRESS, + signMessage: vi.fn().mockResolvedValue(SIGNATURE), + }; + + const result = await buildPersonalServerLiteOwnerBindingSignature({ + signer, + }); + + const message = `vana.account.v1:ps-lite-owner:${LOWER_OWNER_ADDRESS}`; + expect(signer.signMessage).toHaveBeenCalledWith({ message }); + expect(result).toEqual({ + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + message, + purpose: PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + }); + }); + + it("adapts viem wallet clients without hiding the explicit account", async () => { + const walletClient = { + signMessage: vi.fn().mockResolvedValue(SIGNATURE), + }; + const signer = createViemPersonalServerLiteOwnerBindingSigner( + walletClient, + { account: OWNER_ADDRESS }, + ); + + const message = buildPersonalServerLiteOwnerBindingMessage(OWNER_ADDRESS); + await signer.signMessage({ message }); + + expect(signer.address).toBe(OWNER_ADDRESS); + expect(walletClient.signMessage).toHaveBeenCalledWith({ + account: OWNER_ADDRESS, + message, + }); + }); +}); diff --git a/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.ts b/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.ts new file mode 100644 index 00000000..072fd779 --- /dev/null +++ b/packages/vana-sdk/src/protocol/personal-server-lite-owner-binding.ts @@ -0,0 +1,133 @@ +/** + * PS Lite owner-binding message and signing helpers. + * + * PS Lite uses this replayable personal-sign message as a wallet-owned input + * for opening the user's local encrypted runtime. This is intentionally + * separate from Personal Server registration, which is EIP-712 typed data. + * + * @category Protocol + */ + +import { + isAddress, + type Account, + type Address, + type Hex, + type SignableMessage, +} from "viem"; + +export const PERSONAL_SERVER_LITE_OWNER_BINDING_VERSION = "vana.account.v1"; +export const PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE = "ps-lite-owner"; +export const PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX = + `${PERSONAL_SERVER_LITE_OWNER_BINDING_VERSION}:${PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE}:` as const; + +export type PersonalServerLiteOwnerBindingPurpose = + typeof PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE; + +export type PersonalServerLiteOwnerBindingMessage = + `${typeof PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX}${Lowercase
}`; + +export interface PersonalServerLiteOwnerBindingSigner { + address: Address; + signMessage(input: { + message: PersonalServerLiteOwnerBindingMessage; + }): Promise | Hex; +} + +export interface ViemPersonalServerLiteOwnerBindingWalletClient { + account?: Account | Address | null; + signMessage(input: { + account?: Account | Address; + message: SignableMessage; + }): Promise; +} + +export type ViemPersonalServerLiteOwnerBindingSignerSource = + | PersonalServerLiteOwnerBindingSigner + | ViemPersonalServerLiteOwnerBindingWalletClient; + +export interface BuildPersonalServerLiteOwnerBindingSignatureInput { + signer: PersonalServerLiteOwnerBindingSigner; +} + +export interface PersonalServerLiteOwnerBindingSignature { + signature: Hex; + signerAddress: Address; + message: PersonalServerLiteOwnerBindingMessage; + purpose: PersonalServerLiteOwnerBindingPurpose; +} + +function assertAddress(value: Address, name: string): void { + if (!isAddress(value)) { + throw new Error(`${name} must be a valid EVM address`); + } +} + +function getAccountAddress( + account: Account | Address | null | undefined, +): Address | undefined { + if (!account) { + return undefined; + } + + return typeof account === "string" ? account : account.address; +} + +function isPersonalServerLiteOwnerBindingSigner( + source: ViemPersonalServerLiteOwnerBindingSignerSource, +): source is PersonalServerLiteOwnerBindingSigner { + return "address" in source && typeof source.signMessage === "function"; +} + +export function buildPersonalServerLiteOwnerBindingMessage( + ownerAddress: Address, +): PersonalServerLiteOwnerBindingMessage { + assertAddress(ownerAddress, "ownerAddress"); + return `${PERSONAL_SERVER_LITE_OWNER_BINDING_PREFIX}${ownerAddress.toLowerCase()}` as PersonalServerLiteOwnerBindingMessage; +} + +export function createViemPersonalServerLiteOwnerBindingSigner( + source: ViemPersonalServerLiteOwnerBindingSignerSource, + options: { account?: Account | Address } = {}, +): PersonalServerLiteOwnerBindingSigner { + if (isPersonalServerLiteOwnerBindingSigner(source)) { + return source; + } + + const accountAddress = + getAccountAddress(options.account) ?? getAccountAddress(source.account); + + if (accountAddress) { + return { + address: accountAddress, + signMessage: ({ message }) => + source.signMessage({ + account: options.account ?? source.account ?? accountAddress, + message, + }), + }; + } + + throw new Error( + "Viem wallet client requires an account option or account property", + ); +} + +export async function buildPersonalServerLiteOwnerBindingSignature( + input: BuildPersonalServerLiteOwnerBindingSignatureInput, +): Promise { + const message = buildPersonalServerLiteOwnerBindingMessage( + input.signer.address, + ); + const signature = await input.signer.signMessage({ message }); + + return { + signature, + signerAddress: input.signer.address, + message, + purpose: PERSONAL_SERVER_LITE_OWNER_BINDING_PURPOSE, + }; +} + +export const signPersonalServerLiteOwnerBinding = + buildPersonalServerLiteOwnerBindingSignature; diff --git a/packages/vana-sdk/src/protocol/personal-server-registration.test.ts b/packages/vana-sdk/src/protocol/personal-server-registration.test.ts new file mode 100644 index 00000000..33a89f81 --- /dev/null +++ b/packages/vana-sdk/src/protocol/personal-server-registration.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + buildPersonalServerRegistrationSignature, + buildPersonalServerRegistrationTypedData, + createViemPersonalServerRegistrationSigner, + personalServerRegistrationDomain, + registerPersonalServerSignature, + type PersonalServerRegistrationSigner, +} from "./personal-server-registration"; + +const OWNER_ADDRESS = "0x1111111111111111111111111111111111111111"; +const SERVER_ADDRESS = "0x2222222222222222222222222222222222222222"; +const SERVER_PUBLIC_KEY = "did:key:z6MkiPersonalServerPublicKey"; +const SERVER_URL = "https://ps.example.com"; +const SIGNATURE = `0x${"aa".repeat(65)}` as const; + +describe("Personal Server registration", () => { + it("builds the canonical ServerRegistration typed data shape", () => { + expect( + buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + ).toEqual({ + domain: { + name: "Vana Data Portability", + version: "1", + chainId: PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + verifyingContract: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + }, + types: { + ServerRegistration: [ + { name: "ownerAddress", type: "address" }, + { name: "serverAddress", type: "address" }, + { name: "publicKey", type: "string" }, + { name: "serverUrl", type: "string" }, + ], + }, + primaryType: "ServerRegistration", + message: { + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + publicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }, + }); + }); + + it("builds the default domain without requiring unrelated contract config", () => { + expect(personalServerRegistrationDomain()).toEqual({ + name: "Vana Data Portability", + version: "1", + chainId: PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + verifyingContract: + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT, + }); + }); + + it("accepts explicit chain and verifying contract overrides", () => { + const verifyingContract = "0x3333333333333333333333333333333333333333"; + + expect( + buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + chainId: 14800, + verifyingContract, + }).domain, + ).toEqual({ + name: "Vana Data Portability", + version: "1", + chainId: 14800, + verifyingContract, + }); + }); + + it("still accepts an explicit gateway config", () => { + const verifyingContract = "0x3333333333333333333333333333333333333333"; + + expect( + buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + config: { + chainId: 14800, + contracts: { + dataRegistry: "0x4444444444444444444444444444444444444444", + dataPortabilityPermissions: + "0x5555555555555555555555555555555555555555", + dataPortabilityServer: verifyingContract, + dataPortabilityGrantees: + "0x6666666666666666666666666666666666666666", + }, + }, + }).domain, + ).toEqual({ + name: "Vana Data Portability", + version: "1", + chainId: 14800, + verifyingContract, + }); + }); + + it("uses the signer address as ownerAddress and signs the typed data", async () => { + const signer: PersonalServerRegistrationSigner = { + address: OWNER_ADDRESS, + signTypedData: vi.fn().mockResolvedValue(SIGNATURE), + }; + + const result = await buildPersonalServerRegistrationSignature({ + signer, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + + expect(signer.signTypedData).toHaveBeenCalledOnce(); + expect(signer.signTypedData).toHaveBeenCalledWith(result.typedData); + expect(result).toMatchObject({ + signature: SIGNATURE, + signerAddress: OWNER_ADDRESS, + }); + expect(result).not.toHaveProperty("intent"); + expect(result.typedData.message.ownerAddress).toBe(OWNER_ADDRESS); + }); + + it("exports the registerPersonalServerSignature alias", async () => { + const signer: PersonalServerRegistrationSigner = { + address: OWNER_ADDRESS, + signTypedData: () => SIGNATURE, + }; + + await expect( + registerPersonalServerSignature({ + signer, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + ).resolves.toMatchObject({ + signature: SIGNATURE, + }); + }); + + it("adapts a viem local account-style signer", async () => { + const viemAccount = { + address: OWNER_ADDRESS, + signTypedData: vi.fn().mockResolvedValue(SIGNATURE), + }; + const signer = createViemPersonalServerRegistrationSigner(viemAccount); + + const result = await buildPersonalServerRegistrationSignature({ + signer, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + + expect(viemAccount.signTypedData).toHaveBeenCalledWith(result.typedData); + expect(result.signerAddress).toBe(OWNER_ADDRESS); + }); + + it("adapts a viem wallet client-style signer with an account", async () => { + const walletClient = { + signTypedData: vi.fn().mockResolvedValue(SIGNATURE), + }; + const signer = createViemPersonalServerRegistrationSigner(walletClient, { + account: OWNER_ADDRESS, + }); + + const result = await buildPersonalServerRegistrationSignature({ + signer, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }); + + expect(walletClient.signTypedData).toHaveBeenCalledWith({ + ...result.typedData, + account: OWNER_ADDRESS, + }); + }); + + it("rejects invalid address inputs", () => { + expect(() => + buildPersonalServerRegistrationTypedData({ + ownerAddress: "0xnot-an-address", + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + ).toThrow("ownerAddress must be a valid EVM address"); + + expect(() => + buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: "0xnot-an-address", + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + }), + ).toThrow("serverAddress must be a valid EVM address"); + + expect(() => + buildPersonalServerRegistrationTypedData({ + ownerAddress: OWNER_ADDRESS, + serverAddress: SERVER_ADDRESS, + serverPublicKey: SERVER_PUBLIC_KEY, + serverUrl: SERVER_URL, + verifyingContract: "0xnot-an-address", + }), + ).toThrow("verifyingContract must be a valid EVM address"); + }); +}); diff --git a/packages/vana-sdk/src/protocol/personal-server-registration.ts b/packages/vana-sdk/src/protocol/personal-server-registration.ts new file mode 100644 index 00000000..b543f74b --- /dev/null +++ b/packages/vana-sdk/src/protocol/personal-server-registration.ts @@ -0,0 +1,199 @@ +/** + * Personal Server registration typed-data and signing helpers. + * + * These helpers are protocol-owned and runtime-neutral. Apps can sign with + * viem local accounts, wallet clients, Account products, or any equivalent + * signer by adapting to {@link PersonalServerRegistrationSigner}. + * + * @category Protocol + */ + +import { + isAddress, + type Account, + type Address, + type Hex, + type TypedDataDomain, + type TypedDataDefinition, +} from "viem"; +import { + SERVER_REGISTRATION_TYPES, + serverRegistrationDomain, + type DataPortabilityGatewayConfig, + type ServerRegistrationMessage, +} from "./eip712"; + +export const PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID = 1480; +export const PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT = + "0x1483B1F634DBA75AeaE60da7f01A679aabd5ee2c" as const; + +export type PersonalServerRegistrationTypedData = TypedDataDefinition< + typeof SERVER_REGISTRATION_TYPES, + "ServerRegistration" +> & { + message: ServerRegistrationMessage; +}; + +export interface PersonalServerRegistrationSigner { + address: Address; + signTypedData( + typedData: PersonalServerRegistrationTypedData, + ): Promise | Hex; +} + +export interface ViemPersonalServerRegistrationWalletClient { + account?: Account | Address | null; + signTypedData( + typedData: PersonalServerRegistrationTypedData & { + account?: Account | Address; + }, + ): Promise; +} + +export type ViemPersonalServerRegistrationSignerSource = + | PersonalServerRegistrationSigner + | ViemPersonalServerRegistrationWalletClient; + +export interface BuildPersonalServerRegistrationTypedDataInput { + ownerAddress: Address; + serverAddress: Address; + serverPublicKey: string; + serverUrl: string; + config?: DataPortabilityGatewayConfig; + chainId?: number; + verifyingContract?: Address; +} + +export interface BuildPersonalServerRegistrationSignatureInput { + signer: PersonalServerRegistrationSigner; + serverAddress: Address; + serverPublicKey: string; + serverUrl: string; + config?: DataPortabilityGatewayConfig; + chainId?: number; + verifyingContract?: Address; +} + +export interface PersonalServerRegistrationSignature { + signature: Hex; + signerAddress: Address; + typedData: PersonalServerRegistrationTypedData; +} + +export interface PersonalServerRegistrationDomainInput { + config?: DataPortabilityGatewayConfig; + chainId?: number; + verifyingContract?: Address; +} + +function assertAddress(value: Address, name: string): void { + if (!isAddress(value)) { + throw new Error(`${name} must be a valid EVM address`); + } +} + +function getAccountAddress( + account: Account | Address | null | undefined, +): Address | undefined { + if (!account) { + return undefined; + } + + return typeof account === "string" ? account : account.address; +} + +function isPersonalServerRegistrationSigner( + source: ViemPersonalServerRegistrationSignerSource, +): source is PersonalServerRegistrationSigner { + return "address" in source && typeof source.signTypedData === "function"; +} + +export function createViemPersonalServerRegistrationSigner( + source: ViemPersonalServerRegistrationSignerSource, + options: { account?: Account | Address } = {}, +): PersonalServerRegistrationSigner { + if (isPersonalServerRegistrationSigner(source)) { + return source; + } + + const accountAddress = + getAccountAddress(options.account) ?? getAccountAddress(source.account); + + if (accountAddress) { + return { + address: accountAddress, + signTypedData: (typedData) => + source.signTypedData({ + ...typedData, + account: options.account ?? source.account ?? accountAddress, + }), + }; + } + + throw new Error( + "Viem wallet client requires an account option or account property", + ); +} + +export function personalServerRegistrationDomain( + input: PersonalServerRegistrationDomainInput = {}, +): TypedDataDomain { + if (input.config) { + return serverRegistrationDomain(input.config); + } + + const verifyingContract = + input.verifyingContract ?? + PERSONAL_SERVER_REGISTRATION_DEFAULT_VERIFYING_CONTRACT; + assertAddress(verifyingContract, "verifyingContract"); + + return { + name: "Vana Data Portability", + version: "1", + chainId: input.chainId ?? PERSONAL_SERVER_REGISTRATION_DEFAULT_CHAIN_ID, + verifyingContract, + }; +} + +export function buildPersonalServerRegistrationTypedData( + input: BuildPersonalServerRegistrationTypedDataInput, +): PersonalServerRegistrationTypedData { + assertAddress(input.ownerAddress, "ownerAddress"); + assertAddress(input.serverAddress, "serverAddress"); + + return { + domain: personalServerRegistrationDomain(input), + types: SERVER_REGISTRATION_TYPES, + primaryType: "ServerRegistration", + message: { + ownerAddress: input.ownerAddress, + serverAddress: input.serverAddress, + publicKey: input.serverPublicKey, + serverUrl: input.serverUrl, + }, + }; +} + +export async function buildPersonalServerRegistrationSignature( + input: BuildPersonalServerRegistrationSignatureInput, +): Promise { + const typedData = buildPersonalServerRegistrationTypedData({ + ownerAddress: input.signer.address, + serverAddress: input.serverAddress, + serverPublicKey: input.serverPublicKey, + serverUrl: input.serverUrl, + config: input.config, + chainId: input.chainId, + verifyingContract: input.verifyingContract, + }); + const signature = await input.signer.signTypedData(typedData); + + return { + signature, + signerAddress: input.signer.address, + typedData, + }; +} + +export const registerPersonalServerSignature = + buildPersonalServerRegistrationSignature;