From 35611a8156aee637c3283ea145c0a4e2e3780db0 Mon Sep 17 00:00:00 2001 From: kalepail Date: Fri, 5 Jun 2026 12:21:44 -0400 Subject: [PATCH 01/30] fix: harden Protocol 27 auth signing --- bun_tests/9.index.ts | 136 +++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + src/kit.ts | 126 ++++++++++++++++++++++++++++++++++----- 3 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 bun_tests/9.index.ts diff --git a/bun_tests/9.index.ts b/bun_tests/9.index.ts new file mode 100644 index 0000000..883f7ce --- /dev/null +++ b/bun_tests/9.index.ts @@ -0,0 +1,136 @@ +import { strict as assert } from "node:assert"; +import { Address, Keypair, Networks, hash, xdr } from "@stellar/stellar-sdk/minimal"; +import { + assertSignatureExpirationLedger, + buildSignaturePayload, + getAddressCredentials, + usesAddressBoundPayload, +} from "../src/kit"; + +function makeAccount(seedByte: number): string { + return Keypair.fromRawEd25519Seed(Buffer.alloc(32, seedByte)).publicKey(); +} + +function makeInvocation(): xdr.SorobanAuthorizedInvocation { + return new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: xdr.ScAddress.scAddressTypeContract(hash(Buffer.from("contract"))), + functionName: "do_it", + args: [], + }) + ), + subInvocations: [], + }); +} + +function makeAddressCredentials(address: string, expiration = 1): xdr.SorobanAddressCredentials { + return new xdr.SorobanAddressCredentials({ + address: Address.fromString(address).toScAddress(), + nonce: xdr.Int64.fromString("7"), + signatureExpirationLedger: expiration, + signature: xdr.ScVal.scvVoid(), + }); +} + +function makeAuthEntry(address: string): xdr.SorobanAuthorizationEntry { + return new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( + makeAddressCredentials(address) + ), + rootInvocation: makeInvocation(), + }); +} + +const networkPassphrase = Networks.TESTNET; + +{ + const account = makeAccount(1); + const entry = makeAuthEntry(account); + const credentials = getAddressCredentials(entry.credentials()); + + assert.equal(entry.credentials().switch().name, "sorobanCredentialsAddress"); + assert.equal(Address.fromScAddress(credentials.address()).toString(), account); + assert.equal(usesAddressBoundPayload(entry.credentials()), false); +} + +{ + const entry = makeAuthEntry(makeAccount(2)); + const payload = buildSignaturePayload(networkPassphrase, entry, 123); + const credentials = getAddressCredentials(entry.credentials()); + const expectedPreimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( + new xdr.HashIdPreimageSorobanAuthorization({ + networkId: hash(Buffer.from(networkPassphrase)), + nonce: credentials.nonce(), + signatureExpirationLedger: 123, + invocation: entry.rootInvocation(), + }) + ); + + assert.deepEqual(payload, hash(expectedPreimage.toXDR())); + assert.equal(credentials.signatureExpirationLedger(), 123); +} + +{ + const credentials = makeAddressCredentials(makeAccount(3), 1); + const fakeAddressV2Credentials = { + switch: () => ({ name: "sorobanCredentialsAddressV2" }), + addressV2: () => credentials, + } as unknown as xdr.SorobanCredentials; + const fakeAddressV2Entry = { + credentials: () => fakeAddressV2Credentials, + rootInvocation: () => makeInvocation(), + } as unknown as xdr.SorobanAuthorizationEntry; + + assert.equal(usesAddressBoundPayload(fakeAddressV2Credentials), true); + assert.throws( + () => buildSignaturePayload(networkPassphrase, fakeAddressV2Entry, 123), + /Address-bound Soroban auth credentials require an SDK with Protocol 27 auth preimage support/ + ); + assert.equal(credentials.signatureExpirationLedger(), 1); +} + +{ + const fakeAddressV2Credentials = { + switch: () => ({ name: "sorobanCredentialsAddressV2" }), + } as unknown as xdr.SorobanCredentials; + + assert.throws( + () => getAddressCredentials(fakeAddressV2Credentials), + /ADDRESS_V2 credentials require an SDK with Protocol 27 credential support/ + ); +} + +{ + const fakeDelegatedCredentials = { + switch: () => ({ name: "sorobanCredentialsAddressWithDelegates" }), + } as unknown as xdr.SorobanCredentials; + + assert.equal(usesAddressBoundPayload(fakeDelegatedCredentials), true); + assert.throws( + () => getAddressCredentials(fakeDelegatedCredentials), + /ADDRESS_WITH_DELEGATES credentials require an SDK with Protocol 27 credential support/ + ); +} + +{ + assert.doesNotThrow(() => assertSignatureExpirationLedger(0)); + assert.doesNotThrow(() => assertSignatureExpirationLedger(0xffffffff)); + assert.throws(() => assertSignatureExpirationLedger(-1), /uint32 integer/); + assert.throws(() => assertSignatureExpirationLedger(0x100000000), /uint32 integer/); + assert.throws(() => assertSignatureExpirationLedger(1.5), /uint32 integer/); + assert.throws(() => assertSignatureExpirationLedger(Number.NaN), /uint32 integer/); +} + +{ + const entry = makeAuthEntry(makeAccount(4)); + const credentials = getAddressCredentials(entry.credentials()); + + assert.throws( + () => buildSignaturePayload(networkPassphrase, entry, 1.5), + /Soroban signature expiration ledger must be a uint32 integer/ + ); + assert.equal(credentials.signatureExpirationLedger(), 1); +} + +console.log("Protocol 27 auth helper tests passed"); diff --git a/package.json b/package.json index 8f6d2c0..be6090e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "deploy:demo": "npx wrangler pages deploy", "deploy:demo:prod": "npx wrangler pages deploy --env production", "prepublishOnly": "pnpm run build", + "test:p27-auth": "bun bun_tests/9.index.ts", "build:sac": "pnpm --filter=sac-sdk run build", "build:pks": "pnpm --filter=passkey-kit-sdk run build", "build": "pnpm run build:sac && pnpm run build:pks && tsc", diff --git a/src/kit.ts b/src/kit.ts index b1086b8..7653524 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -8,6 +8,109 @@ import { PasskeyBase } from './base' import { AssembledTransaction, basicNodeSigner, type AssembledTransactionOptions, type Tx } from '@stellar/stellar-sdk/minimal/contract' import type { Server } from '@stellar/stellar-sdk/minimal/rpc' +type P27HashIdPreimage = typeof xdr.HashIdPreimage & { + envelopeTypeSorobanAuthorizationWithAddress?: (value: unknown) => xdr.HashIdPreimage +} + +type P27Xdr = typeof xdr & { + HashIdPreimageSorobanAuthorizationWithAddress?: new (fields: { + networkId: Buffer + nonce: xdr.Int64 + signatureExpirationLedger: number + invocation: xdr.SorobanAuthorizedInvocation + address: xdr.ScAddress + }) => unknown +} + +export function getAddressCredentials(credentials: xdr.SorobanCredentials): xdr.SorobanAddressCredentials { + const typedCredentials = credentials as unknown as { + address: () => xdr.SorobanAddressCredentials + addressV2?: () => xdr.SorobanAddressCredentials + addressWithDelegates?: () => { + addressCredentials: () => xdr.SorobanAddressCredentials + } + } + + switch (credentials.switch().name as string) { + case 'sorobanCredentialsAddress': + return typedCredentials.address() + case 'sorobanCredentialsAddressV2': + if (!typedCredentials.addressV2) { + throw new Error( + 'ADDRESS_V2 credentials require an SDK with Protocol 27 credential support' + ) + } + return typedCredentials.addressV2() + case 'sorobanCredentialsAddressWithDelegates': + if (!typedCredentials.addressWithDelegates) { + throw new Error( + 'ADDRESS_WITH_DELEGATES credentials require an SDK with Protocol 27 credential support' + ) + } + return typedCredentials.addressWithDelegates().addressCredentials() + } + + throw new Error(`Soroban credentials do not contain address credentials: ${credentials.switch().name}`) +} + +export function usesAddressBoundPayload(credentials: xdr.SorobanCredentials): boolean { + const credentialType = credentials.switch().name as string + return credentialType === 'sorobanCredentialsAddressV2' + || credentialType === 'sorobanCredentialsAddressWithDelegates' +} + +export function assertSignatureExpirationLedger(expiration: number): void { + if (!Number.isInteger(expiration) || expiration < 0 || expiration > 0xffffffff) { + throw new Error('Soroban signature expiration ledger must be a uint32 integer') + } +} + +export function buildSignaturePayload( + networkPassphrase: string, + entry: xdr.SorobanAuthorizationEntry, + expiration: number +): Buffer { + assertSignatureExpirationLedger(expiration) + + const credentials = getAddressCredentials(entry.credentials()) + + if (usesAddressBoundPayload(entry.credentials())) { + const hashIdPreimage = xdr.HashIdPreimage as P27HashIdPreimage + const WithAddressPreimage = (xdr as P27Xdr).HashIdPreimageSorobanAuthorizationWithAddress + + if (!hashIdPreimage.envelopeTypeSorobanAuthorizationWithAddress || !WithAddressPreimage) { + throw new Error( + 'Address-bound Soroban auth credentials require an SDK with Protocol 27 auth preimage support' + ) + } + + credentials.signatureExpirationLedger(expiration) + const preimage = hashIdPreimage.envelopeTypeSorobanAuthorizationWithAddress( + new WithAddressPreimage({ + networkId: hash(Buffer.from(networkPassphrase)), + nonce: credentials.nonce(), + signatureExpirationLedger: expiration, + invocation: entry.rootInvocation(), + address: credentials.address(), + }) + ) + + return hash(preimage.toXDR()) + } + + credentials.signatureExpirationLedger(expiration) + const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( + new xdr.HashIdPreimageSorobanAuthorization({ + networkId: hash(Buffer.from(networkPassphrase)), + nonce: credentials.nonce(), + signatureExpirationLedger: expiration, + invocation: entry.rootInvocation() + }) + ) + + return hash(preimage.toXDR()) +} + // TODO we should allow setting the rpId in the constructor so we don't have to keep setting it for every call // e.g. if you want to sign with a smol.xyz key on next.smol.xyz key you have to set rpId over and over again @@ -247,7 +350,12 @@ export class PasskeyKit extends PasskeyBase { if ([keyId, keypair, policy].filter((arg) => !!arg).length > 1) throw new Error('Exactly one of `options.keyId`, `options.keypair`, or `options.policy` must be provided.'); - const credentials = entry.credentials().address(); + const credentialType = entry.credentials().switch().name as string + if (credentialType === 'sorobanCredentialsAddressWithDelegates') { + throw new Error('ADDRESS_WITH_DELEGATES auth entries are not supported by passkey signing yet') + } + + const credentials = getAddressCredentials(entry.credentials()); if (!expiration) { expiration = credentials.signatureExpirationLedger() @@ -255,21 +363,11 @@ export class PasskeyKit extends PasskeyBase { if (!expiration) { const { sequence } = await this.rpc.getLatestLedger() expiration = sequence + this.timeoutInSeconds / 5; // assumes 5 second ledger time + expiration = Math.ceil(expiration) } } - credentials.signatureExpirationLedger(expiration) - - const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( - new xdr.HashIdPreimageSorobanAuthorization({ - networkId: hash(Buffer.from(this.networkPassphrase)), - nonce: credentials.nonce(), - signatureExpirationLedger: credentials.signatureExpirationLedger(), - invocation: entry.rootInvocation() - }) - ) - - const payload = hash(preimage.toXDR()) + const payload = buildSignaturePayload(this.networkPassphrase, entry, expiration) // let signatures: Signatures let key: SDKSignerKey @@ -695,4 +793,4 @@ export class PasskeyKit extends PasskeyBase { return concatSignature; } -} \ No newline at end of file +} From 4cdbd0744066b9ab71df5738045bc19a900a0aa3 Mon Sep 17 00:00:00 2001 From: kalepail Date: Mon, 8 Jun 2026 15:00:23 -0400 Subject: [PATCH 02/30] fix: prepare stellar sdk v16 rc support --- bun_tests/5.index.ts | 2 +- bun_tests/7.index.ts | 4 +- bun_tests/9.index.ts | 84 ++++-- demo/package.json | 5 +- demo/pnpm-lock.yaml | 383 +++++++++----------------- demo/src/App.svelte | 2 +- demo/src/lib/common.ts | 6 +- package.json | 6 +- packages/passkey-kit-sdk/package.json | 5 +- packages/passkey-kit-sdk/src/index.ts | 4 +- packages/sac-sdk/package.json | 5 +- packages/sac-sdk/src/index.ts | 4 +- pnpm-lock.yaml | 345 +++++++++-------------- src/base.ts | 2 +- src/kit.ts | 39 ++- src/sac.ts | 2 +- src/server.ts | 8 +- 17 files changed, 398 insertions(+), 508 deletions(-) diff --git a/bun_tests/5.index.ts b/bun_tests/5.index.ts index 16bb31d..9b9b320 100644 --- a/bun_tests/5.index.ts +++ b/bun_tests/5.index.ts @@ -1,7 +1,7 @@ // Build an assembled transaction manually import { PasskeyClient } from 'passkey-kit' -import { AssembledTransaction } from '@stellar/stellar-sdk/minimal/contract'; +import { AssembledTransaction } from '@stellar/stellar-sdk/contract'; const tx_xdr = 'AAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACZ30AAAAAAAAAAQAAAAEAAAAAAAAAAAAAAABm+xLbAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAIdHJhbnNmZXIAAAADAAAAEgAAAAGeKf+NLk3Jb5f2jCWLsZdKH31uJ94AOPposffyxd+2qAAAABIAAAABh/ZytVEXZeou5dLsuMEZdkkVCjaICnCTq0CmRionsIgAAAAKAAAAAAAAAAAAAAAAAJiWgAAAAAEAAAABAAAAAZ4p/40uTclvl/aMJYuxl0offW4n3gA4+mix9/LF37aoSOpiY5Ioi98AA0z8AAAAEAAAAAEAAAABAAAAEQAAAAEAAAABAAAAEAAAAAEAAAACAAAADwAAAAlTZWNwMjU2cjEAAAAAAAANAAAAFKrB2chB1uk2vswAZo5AakLnkqG/AAAAEAAAAAEAAAACAAAADwAAAAlTZWNwMjU2cjEAAAAAAAARAAAAAQAAAAMAAAAPAAAAEmF1dGhlbnRpY2F0b3JfZGF0YQAAAAAADQAAACXKupuc4WurWb3NCdWC5nW7akt6c3YA07IZIg7j6tAwSR0AAAAAAAAAAAAADwAAABBjbGllbnRfZGF0YV9qc29uAAAADQAAAJh7InR5cGUiOiJ3ZWJhdXRobi5nZXQiLCJjaGFsbGVuZ2UiOiJ5Zi1wZWlsRGlEN002cmhMZWZlMU8zQV80ZlE5STZOVGVIT0YwZ1ljVk00Iiwib3JpZ2luIjoiaHR0cHM6Ly9uZXh0LnBhc3NrZXkta2l0LWRlbW8ucGFnZXMuZGV2IiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQAAAA8AAAAJc2lnbmF0dXJlAAAAAAAADQAAAEAtCj4OlKr5N+YX6ny5jYu5XXmGEq9adAQr/GSm9nPOqhDoqWDRL0yD466GjttJhp235xorK0sLB5xgWeLON2VMAAAAAAAAAAHXkotywnA8z+r365/0701QSlWouXn8m0UOoshCtNHOYQAAAAh0cmFuc2ZlcgAAAAMAAAASAAAAAZ4p/40uTclvl/aMJYuxl0offW4n3gA4+mix9/LF37aoAAAAEgAAAAGH9nK1URdl6i7l0uy4wRl2SRUKNogKcJOrQKZGKiewiAAAAAoAAAAAAAAAAAAAAAAAmJaAAAAAAAAAAAEAAAAAAAAAAQAAAAYAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAUAAAAAQAAAAMAAAAGAAAAAZ4p/40uTclvl/aMJYuxl0offW4n3gA4+mix9/LF37aoAAAAFUjqYmOSKIvfAAAAAAAAAAYAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAQAAAAAQAAAAIAAAAPAAAAB0JhbGFuY2UAAAAAEgAAAAGH9nK1URdl6i7l0uy4wRl2SRUKNogKcJOrQKZGKiewiAAAAAEAAAAGAAAAAdeSi3LCcDzP6vfrn/TvTVBKVai5efybRQ6iyEK00c5hAAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAABnin/jS5NyW+X9owli7GXSh99bifeADj6aLH38sXftqgAAAABAAWL7QAAArgAAAIIAAAAAAACZxkAAAAA' diff --git a/bun_tests/7.index.ts b/bun_tests/7.index.ts index 9b9edee..efcaa0c 100644 --- a/bun_tests/7.index.ts +++ b/bun_tests/7.index.ts @@ -1,7 +1,7 @@ // Test an XDR that deploys a contract -import { Networks, Operation, TransactionBuilder, xdr } from '@stellar/stellar-sdk/minimal'; -import { AssembledTransaction, type AssembledTransactionOptions, type Tx } from '@stellar/stellar-sdk/minimal/contract'; +import { Networks, Operation, TransactionBuilder, xdr } from '@stellar/stellar-sdk'; +import { AssembledTransaction, type AssembledTransactionOptions, type Tx } from '@stellar/stellar-sdk/contract'; import { Client as PasskeyClient } from 'passkey-kit-sdk'; let xdr_base64 = 'AAAAAgAAAAC4YTsgfBnWL+VK8itDD/gCnqtirv5cWHO3MXxqONNVmgAAAGQAESPAAAAAAQAAAAEAAAAAAAAAAAAAAABnrkStAAAAAAAAAAEAAAAAAAAAGAAAAAMAAAAAAAAAAAAAAAC4YTsgfBnWL+VK8itDD/gCnqtirv5cWHO3MXxqONNVmvsvbA+ypiNL9AWBaQhdhXtpba5tIv74SARYfnqYLTICAAAAAMH9pZirYl7himeZ8T6ddEuJwyaE/xqaJTNnneY4XZSXAAAAAwAAABIAAAABg53yKxwbQoljlymK88aXMGcZomvOAI8tWIoxYZ7dk3UAAAASAAAAAdeSi3LCcDzP6vfrn/TvTVBKVai5efybRQ6iyEK00c5hAAAAAwAAAAYAAAAAAAAAAAAAAAA='; diff --git a/bun_tests/9.index.ts b/bun_tests/9.index.ts index 883f7ce..f682575 100644 --- a/bun_tests/9.index.ts +++ b/bun_tests/9.index.ts @@ -1,5 +1,13 @@ import { strict as assert } from "node:assert"; -import { Address, Keypair, Networks, hash, xdr } from "@stellar/stellar-sdk/minimal"; +import { + Address, + Keypair, + Networks, + buildAuthorizationEntryPreimage, + buildWithDelegatesEntry, + hash, + xdr, +} from "@stellar/stellar-sdk"; import { assertSignatureExpirationLedger, buildSignaturePayload, @@ -58,13 +66,11 @@ const networkPassphrase = Networks.TESTNET; const entry = makeAuthEntry(makeAccount(2)); const payload = buildSignaturePayload(networkPassphrase, entry, 123); const credentials = getAddressCredentials(entry.credentials()); - const expectedPreimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( - new xdr.HashIdPreimageSorobanAuthorization({ - networkId: hash(Buffer.from(networkPassphrase)), - nonce: credentials.nonce(), - signatureExpirationLedger: 123, - invocation: entry.rootInvocation(), - }) + const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR()); + const expectedPreimage = buildAuthorizationEntryPreimage( + expectedEntry, + 123, + networkPassphrase ); assert.deepEqual(payload, hash(expectedPreimage.toXDR())); @@ -72,22 +78,56 @@ const networkPassphrase = Networks.TESTNET; } { - const credentials = makeAddressCredentials(makeAccount(3), 1); - const fakeAddressV2Credentials = { - switch: () => ({ name: "sorobanCredentialsAddressV2" }), - addressV2: () => credentials, - } as unknown as xdr.SorobanCredentials; - const fakeAddressV2Entry = { - credentials: () => fakeAddressV2Credentials, - rootInvocation: () => makeInvocation(), - } as unknown as xdr.SorobanAuthorizationEntry; + const baseEntry = makeAuthEntry(makeAccount(3)); + const credentials = getAddressCredentials(baseEntry.credentials()); + const credentialFactory = xdr.SorobanCredentials as unknown as { + sorobanCredentialsAddressV2: ( + credentials: xdr.SorobanAddressCredentials + ) => xdr.SorobanCredentials; + }; + const addressV2Entry = new xdr.SorobanAuthorizationEntry({ + credentials: credentialFactory.sorobanCredentialsAddressV2(credentials), + rootInvocation: baseEntry.rootInvocation(), + }); + const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR(addressV2Entry.toXDR()); + const expectedPreimage = buildAuthorizationEntryPreimage( + expectedEntry, + 123, + networkPassphrase + ); - assert.equal(usesAddressBoundPayload(fakeAddressV2Credentials), true); - assert.throws( - () => buildSignaturePayload(networkPassphrase, fakeAddressV2Entry, 123), - /Address-bound Soroban auth credentials require an SDK with Protocol 27 auth preimage support/ + assert.equal(addressV2Entry.credentials().switch().name, "sorobanCredentialsAddressV2"); + assert.equal(usesAddressBoundPayload(addressV2Entry.credentials()), true); + assert.deepEqual( + buildSignaturePayload(networkPassphrase, addressV2Entry, 123), + hash(expectedPreimage.toXDR()) ); - assert.equal(credentials.signatureExpirationLedger(), 1); + assert.equal(credentials.signatureExpirationLedger(), 123); +} + +{ + const delegatedEntry = buildWithDelegatesEntry({ + entry: makeAuthEntry(makeAccount(4)), + validUntilLedgerSeq: 123, + delegates: [{ address: makeAccount(5) }], + }); + const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR(delegatedEntry.toXDR()); + const expectedPreimage = buildAuthorizationEntryPreimage( + expectedEntry, + 123, + networkPassphrase + ); + + assert.equal( + delegatedEntry.credentials().switch().name, + "sorobanCredentialsAddressWithDelegates" + ); + assert.equal(usesAddressBoundPayload(delegatedEntry.credentials()), true); + assert.deepEqual( + buildSignaturePayload(networkPassphrase, delegatedEntry, 123), + hash(expectedPreimage.toXDR()) + ); + assert.equal(getAddressCredentials(delegatedEntry.credentials()).signatureExpirationLedger(), 123); } { diff --git a/demo/package.json b/demo/package.json index d7d7bcc..a15fb4e 100644 --- a/demo/package.json +++ b/demo/package.json @@ -9,7 +9,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "devDependencies": { - "@stellar/stellar-sdk": "^14.1.1", + "@stellar/stellar-sdk": "16.0.0-rc.1", "@sveltejs/vite-plugin-svelte": "^3.1.2", "@tsconfig/svelte": "^5.0.5", "base64url": "^3.0.1", @@ -26,5 +26,8 @@ "overrides": { "@stellar/stellar-sdk": "$@stellar/stellar-sdk" } + }, + "engines": { + "node": ">=22.0.0" } } diff --git a/demo/pnpm-lock.yaml b/demo/pnpm-lock.yaml index dc9601f..bf8b859 100644 --- a/demo/pnpm-lock.yaml +++ b/demo/pnpm-lock.yaml @@ -5,18 +5,18 @@ settings: excludeLinksFromLockfile: false overrides: - '@stellar/stellar-sdk': 14.1.1 + '@stellar/stellar-sdk': 16.0.0-rc.1 importers: .: devDependencies: '@stellar/stellar-sdk': - specifier: 14.1.1 - version: 14.1.1 + specifier: 16.0.0-rc.1 + version: 16.0.0-rc.1 '@sveltejs/vite-plugin-svelte': specifier: ^3.1.2 - version: 3.1.2(svelte@4.2.19)(vite@5.4.14) + version: 3.1.2(svelte@4.2.20)(vite@5.4.21) '@tsconfig/svelte': specifier: ^5.0.5 version: 5.0.5 @@ -30,11 +30,11 @@ importers: specifier: link:.. version: link:.. svelte: - specifier: ^4.2.19 - version: 4.2.19 + specifier: ^4.2.20 + version: 4.2.20 svelte-check: specifier: ^4.3.1 - version: 4.3.1(svelte@4.2.19)(typescript@5.9.2) + version: 4.3.1(svelte@4.2.20)(typescript@5.9.2) tslib: specifier: ^2.8.1 version: 2.8.1 @@ -42,8 +42,8 @@ importers: specifier: ^5.9.2 version: 5.9.2 vite: - specifier: ^5.4.14 - version: 5.4.14 + specifier: ^5.4.19 + version: 5.4.21 vite-plugin-filter-replace: specifier: ^0.1.14 version: 0.1.14 @@ -210,13 +210,12 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@noble/curves@1.9.7': - resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} - engines: {node: ^14.21.3 || >=16} + '@noble/ed25519@3.1.0': + resolution: {integrity: sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==} - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} '@rollup/rollup-android-arm-eabi@4.36.0': resolution: {integrity: sha512-jgrXjjcEwN6XpZXL0HUeOVGfjXhPyxAbbhD0BlXUB+abTOpbPiN5Wb3kOT7yb+uEtATNYF5x5gIfwutmuBA26w==} @@ -252,51 +251,61 @@ packages: resolution: {integrity: sha512-bvXVU42mOVcF4le6XSjscdXjqx8okv4n5vmwgzcmtvFdifQ5U4dXFYaCB87namDRKlUL9ybVtLQ9ztnawaSzvg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.36.0': resolution: {integrity: sha512-JFIQrDJYrxOnyDQGYkqnNBtjDwTgbasdbUiQvcU8JmGDfValfH1lNpng+4FWlhaVIR4KPkeddYjsVVbmJYvDcg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.36.0': resolution: {integrity: sha512-KqjYVh3oM1bj//5X7k79PSCZ6CvaVzb7Qs7VMWS+SlWB5M8p3FqufLP9VNp4CazJ0CsPDLwVD9r3vX7Ci4J56A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.36.0': resolution: {integrity: sha512-QiGnhScND+mAAtfHqeT+cB1S9yFnNQ/EwCg5yE3MzoaZZnIV0RV9O5alJAoJKX/sBONVKeZdMfO8QSaWEygMhw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.36.0': resolution: {integrity: sha512-1ZPyEDWF8phd4FQtTzMh8FQwqzvIjLsl6/84gzUxnMNFBtExBtpL51H67mV9xipuxl1AEAerRBgBwFNpkw8+Lg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.36.0': resolution: {integrity: sha512-VMPMEIUpPFKpPI9GZMhJrtu8rxnp6mJR3ZzQPykq4xc2GmdHj3Q4cA+7avMyegXy4n1v+Qynr9fR88BmyO74tg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.36.0': resolution: {integrity: sha512-ttE6ayb/kHwNRJGYLpuAvB7SMtOeQnVXEIpMtAvx3kepFQeowVED0n1K9nAdraHUPJ5hydEMxBpIR7o4nrm8uA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.36.0': resolution: {integrity: sha512-4a5gf2jpS0AIe7uBjxDeUMNcFmaRTbNv7NxI5xOCs4lhzsVyGR/0qBXduPnoWf6dGC365saTiwag8hP1imTgag==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.36.0': resolution: {integrity: sha512-5KtoW8UWmwFKQ96aQL3LlRXX16IMwyzMq/jSSVIIyAANiE1doaQsx/KRyhAvpHlPjPiSU/AYX/8m+lQ9VToxFQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.36.0': resolution: {integrity: sha512-sycrYZPrv2ag4OCvaN5js+f01eoZ2U+RmT5as8vhxiFz+kxwlHrsxOwKPSA8WyS+Wc6Epid9QeI/IkQ9NkgYyQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.36.0': resolution: {integrity: sha512-qbqt4N7tokFwwSVlWDsjfoHgviS3n/vZ8LK0h1uLG9TYIRuUTJC88E1xb3LM2iqZ/WTqNQjYrtmtGmrmmawB6A==} @@ -313,16 +322,14 @@ packages: cpu: [x64] os: [win32] - '@stellar/js-xdr@3.1.2': - resolution: {integrity: sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==} + '@stellar/js-xdr@4.0.0': + resolution: {integrity: sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==} + engines: {node: '>=20.0.0', pnpm: '>=9.0.0'} - '@stellar/stellar-base@14.0.0': - resolution: {integrity: sha512-CM84WNbj1GoB4FSWof4In60I6+m5ja0jbUFGKFmpYxabbgiU3Nmf29k9ZM9rkFwdyApgG2kFrB5WEwwoOHSmVA==} - engines: {node: '>=20.0.0'} - - '@stellar/stellar-sdk@14.1.1': - resolution: {integrity: sha512-yu9E9fENEOgt26U/YaApQUUn6TRRhnEzzEOey3y43Nf4l08nbUmlzWYLMl9lcEzEilM68D3ENnEWxBuPylKLQQ==} - engines: {node: '>=20.0.0'} + '@stellar/stellar-sdk@16.0.0-rc.1': + resolution: {integrity: sha512-mkOQUsFgepFIxRFJLgjkLAkYQDBdNKgLpMM5yIWQcCUoXavb0gO1Dlw/hPqVg2KBKUKaXs2ljVLuB4x6bSWR0g==} + engines: {node: '>=22.0.0'} + hasBin: true '@sveltejs/vite-plugin-svelte-inspector@2.1.0': resolution: {integrity: sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==} @@ -350,6 +357,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -357,12 +368,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - axios@1.9.0: - resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -379,8 +386,8 @@ packages: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} - bignumber.js@9.3.1: - resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bignumber.js@11.1.3: + resolution: {integrity: sha512-+esZiNSo6VgFokTsYX6mYqNJfFd/IczzZCd4Z7cR8e+AQWhvIcj6nqQ1h9814D9u/TApU0jjTVmfWL0Pd1ZBdA==} buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -389,14 +396,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -408,6 +407,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + css-tree@2.3.1: resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -425,10 +428,6 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -461,9 +460,13 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - eventsource@2.0.2: - resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} - engines: {node: '>=12.0.0'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@4.1.0: + resolution: {integrity: sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==} + engines: {node: '>=20.0.0'} fdir@6.4.3: resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} @@ -476,8 +479,8 @@ packages: feaxios@0.0.23: resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -485,12 +488,8 @@ packages: debug: optional: true - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} fsevents@2.3.3: @@ -513,9 +512,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -528,16 +524,13 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -545,13 +538,6 @@ packages: resolution: {integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==} engines: {node: '>=12'} - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -595,19 +581,13 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - postcss@8.5.3: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} @@ -622,17 +602,9 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - sha.js@2.4.12: - resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} - engines: {node: '>= 0.10'} - hasBin: true + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -652,37 +624,27 @@ packages: peerDependencies: svelte: ^3.19.0 || ^4.0.0 - svelte@4.2.19: - resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==} + svelte@4.2.20: + resolution: {integrity: sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==} engines: {node: '>=16'} - to-buffer@1.2.1: - resolution: {integrity: sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==} - engines: {node: '>= 0.4'} - - toml@3.0.0: - resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true - urijs@1.19.11: - resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} vite-plugin-filter-replace@0.1.14: resolution: {integrity: sha512-3onEAYgjJQTCJN+/f8+1vOUH3X/tRmXsfxmgH3QtpReGKi+xtP1jMpAEnYsNqw9mI2shZmxFm3LS+n52XczwYQ==} - vite@5.4.14: - resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==} + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -720,10 +682,6 @@ packages: vite: optional: true - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} - engines: {node: '>= 0.4'} - snapshots: '@ampproject/remapping@2.3.0': @@ -817,11 +775,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@noble/curves@1.9.7': - dependencies: - '@noble/hashes': 1.8.0 + '@noble/ed25519@3.1.0': {} - '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} '@rollup/rollup-android-arm-eabi@4.36.0': optional: true @@ -880,50 +836,46 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.36.0': optional: true - '@stellar/js-xdr@3.1.2': {} + '@stellar/js-xdr@4.0.0': {} - '@stellar/stellar-base@14.0.0': + '@stellar/stellar-sdk@16.0.0-rc.1': dependencies: - '@noble/curves': 1.9.7 - '@stellar/js-xdr': 3.1.2 + '@noble/ed25519': 3.1.0 + '@noble/hashes': 2.2.0 + '@stellar/js-xdr': 4.0.0 + axios: 1.16.1 base32.js: 0.1.0 - bignumber.js: 9.3.1 + bignumber.js: 11.1.3 buffer: 6.0.3 - sha.js: 2.4.12 - - '@stellar/stellar-sdk@14.1.1': - dependencies: - '@stellar/stellar-base': 14.0.0 - axios: 1.9.0 - bignumber.js: 9.3.1 - eventsource: 2.0.2 + commander: 14.0.3 + eventsource: 4.1.0 feaxios: 0.0.23 - randombytes: 2.1.0 - toml: 3.0.0 - urijs: 1.19.11 + smol-toml: 1.6.1 + uint8array-extras: 1.5.0 transitivePeerDependencies: - debug + - supports-color - '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.14))(svelte@4.2.19)(vite@5.4.14)': + '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.21))(svelte@4.2.20)(vite@5.4.21)': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.14) + '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.20)(vite@5.4.21) debug: 4.4.0 - svelte: 4.2.19 - vite: 5.4.14 + svelte: 4.2.20 + vite: 5.4.21 transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.14)': + '@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.21)': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.14))(svelte@4.2.19)(vite@5.4.14) + '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.21))(svelte@4.2.20)(vite@5.4.21) debug: 4.4.0 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.17 - svelte: 4.2.19 - svelte-hmr: 0.16.0(svelte@4.2.19) - vite: 5.4.14 - vitefu: 0.2.5(vite@5.4.14) + svelte: 4.2.20 + svelte-hmr: 0.16.0(svelte@4.2.20) + vite: 5.4.21 + vitefu: 0.2.5(vite@5.4.21) transitivePeerDependencies: - supports-color @@ -933,21 +885,25 @@ snapshots: acorn@8.14.1: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + aria-query@5.3.2: {} asynckit@0.4.0: {} - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - axios@1.9.0: + axios@1.16.1: dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.2 - proxy-from-env: 1.1.0 + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color axobject-query@4.1.0: {} @@ -957,7 +913,7 @@ snapshots: base64url@3.0.1: {} - bignumber.js@9.3.1: {} + bignumber.js@11.1.3: {} buffer@6.0.3: dependencies: @@ -969,18 +925,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -997,6 +941,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@14.0.3: {} + css-tree@2.3.1: dependencies: mdn-data: 2.0.30 @@ -1008,12 +954,6 @@ snapshots: deepmerge@4.3.1: {} - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - delayed-stream@1.0.0: {} dunder-proto@1.0.1: @@ -1067,7 +1007,11 @@ snapshots: dependencies: '@types/estree': 1.0.6 - eventsource@2.0.2: {} + eventsource-parser@3.1.0: {} + + eventsource@4.1.0: + dependencies: + eventsource-parser: 3.1.0 fdir@6.4.3: {} @@ -1075,17 +1019,14 @@ snapshots: dependencies: is-retry-allowed: 3.0.0 - follow-redirects@1.15.9: {} - - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 + follow-redirects@1.16.0: {} - form-data@4.0.2: + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 + hasown: 2.0.2 mime-types: 2.1.35 fsevents@2.3.3: @@ -1113,10 +1054,6 @@ snapshots: gopd@1.2.0: {} - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -1127,11 +1064,14 @@ snapshots: dependencies: function-bind: 1.1.2 - ieee754@1.2.1: {} - - inherits@2.0.4: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color - is-callable@1.2.7: {} + ieee754@1.2.1: {} is-reference@3.0.3: dependencies: @@ -1139,12 +1079,6 @@ snapshots: is-retry-allowed@3.0.0: {} - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.19 - - isarray@2.0.5: {} - kleur@4.1.5: {} locate-character@3.0.0: {} @@ -1177,19 +1111,13 @@ snapshots: picocolors@1.1.1: {} - possible-typed-array-names@1.1.0: {} - postcss@8.5.3: dependencies: nanoid: 3.3.10 picocolors: 1.1.1 source-map-js: 1.2.1 - proxy-from-env@1.1.0: {} - - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 + proxy-from-env@2.1.0: {} readdirp@4.1.2: {} @@ -1222,42 +1150,27 @@ snapshots: dependencies: mri: 1.2.0 - safe-buffer@5.2.1: {} - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - sha.js@2.4.12: - dependencies: - inherits: 2.0.4 - safe-buffer: 5.2.1 - to-buffer: 1.2.1 + smol-toml@1.6.1: {} source-map-js@1.2.1: {} - svelte-check@4.3.1(svelte@4.2.19)(typescript@5.9.2): + svelte-check@4.3.1(svelte@4.2.20)(typescript@5.9.2): dependencies: '@jridgewell/trace-mapping': 0.3.25 chokidar: 4.0.3 fdir: 6.4.3 picocolors: 1.1.1 sade: 1.8.1 - svelte: 4.2.19 + svelte: 4.2.20 typescript: 5.9.2 transitivePeerDependencies: - picomatch - svelte-hmr@0.16.0(svelte@4.2.19): + svelte-hmr@0.16.0(svelte@4.2.20): dependencies: - svelte: 4.2.19 + svelte: 4.2.20 - svelte@4.2.19: + svelte@4.2.20: dependencies: '@ampproject/remapping': 2.3.0 '@jridgewell/sourcemap-codec': 1.5.0 @@ -1274,31 +1187,17 @@ snapshots: magic-string: 0.30.17 periscopic: 3.1.0 - to-buffer@1.2.1: - dependencies: - isarray: 2.0.5 - safe-buffer: 5.2.1 - typed-array-buffer: 1.0.3 - - toml@3.0.0: {} - tslib@2.8.1: {} - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - typescript@5.9.2: {} - urijs@1.19.11: {} + uint8array-extras@1.5.0: {} vite-plugin-filter-replace@0.1.14: dependencies: magic-string: 0.30.17 - vite@5.4.14: + vite@5.4.21: dependencies: esbuild: 0.21.5 postcss: 8.5.3 @@ -1306,16 +1205,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - vitefu@0.2.5(vite@5.4.14): + vitefu@0.2.5(vite@5.4.21): optionalDependencies: - vite: 5.4.14 - - which-typed-array@1.1.19: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 + vite: 5.4.21 diff --git a/demo/src/App.svelte b/demo/src/App.svelte index 385e75b..87564d7 100644 --- a/demo/src/App.svelte +++ b/demo/src/App.svelte @@ -8,7 +8,7 @@ native, server, } from "./lib/common"; - import { Keypair } from "@stellar/stellar-sdk/minimal"; + import { Keypair } from "@stellar/stellar-sdk"; import { SignerStore, SignerKey, type SignerLimits, type Signer } from "passkey-kit"; // TODO need to support two toggles: diff --git a/demo/src/lib/common.ts b/demo/src/lib/common.ts index 0adc1a9..8a298a3 100644 --- a/demo/src/lib/common.ts +++ b/demo/src/lib/common.ts @@ -1,8 +1,8 @@ import { PasskeyKit, PasskeyServer, SACClient } from "passkey-kit"; -import { Account, Keypair, StrKey } from "@stellar/stellar-sdk/minimal" +import { Account, Keypair, StrKey } from "@stellar/stellar-sdk" import { Buffer } from "buffer"; -import { basicNodeSigner } from "@stellar/stellar-sdk/minimal/contract"; -import { Server } from "@stellar/stellar-sdk/minimal/rpc"; +import { basicNodeSigner } from "@stellar/stellar-sdk/contract"; +import { Server } from "@stellar/stellar-sdk/rpc"; export const rpc = new Server(import.meta.env.VITE_rpcUrl); diff --git a/package.json b/package.json index be6090e..8014f29 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "dependencies": { "@openzeppelin/relayer-plugin-channels": "^0.5.0", "@simplewebauthn/browser": "^13.2.0", - "@stellar/stellar-sdk": "^14.2.0", + "@stellar/stellar-sdk": "16.0.0-rc.1", "base64url": "^3.0.1", "buffer": "^6.0.3", "passkey-kit-sdk": "workspace:*", @@ -39,8 +39,8 @@ "workspaces": [ "packages/*" ], - "engine": { - "node": ">=20" + "engines": { + "node": ">=22.18.0" }, "repository": { "type": "git", diff --git a/packages/passkey-kit-sdk/package.json b/packages/passkey-kit-sdk/package.json index 15164ec..7de64a8 100644 --- a/packages/passkey-kit-sdk/package.json +++ b/packages/passkey-kit-sdk/package.json @@ -9,10 +9,13 @@ "build": "tsc" }, "dependencies": { - "@stellar/stellar-sdk": "^14.2.0", + "@stellar/stellar-sdk": "16.0.0-rc.1", "buffer": "^6.0.3" }, "devDependencies": { "typescript": "^5.9.2" + }, + "engines": { + "node": ">=22.0.0" } } diff --git a/packages/passkey-kit-sdk/src/index.ts b/packages/passkey-kit-sdk/src/index.ts index c405224..8fd4167 100644 --- a/packages/passkey-kit-sdk/src/index.ts +++ b/packages/passkey-kit-sdk/src/index.ts @@ -5,11 +5,11 @@ import { ClientOptions as ContractClientOptions, MethodOptions, Spec as ContractSpec, -} from '@stellar/stellar-sdk/minimal/contract'; +} from '@stellar/stellar-sdk/contract'; import type { u32, Option, -} from '@stellar/stellar-sdk/minimal/contract'; +} from '@stellar/stellar-sdk/contract'; if (typeof window !== 'undefined') { //@ts-ignore Buffer exists diff --git a/packages/sac-sdk/package.json b/packages/sac-sdk/package.json index a89ea55..0d89bc3 100644 --- a/packages/sac-sdk/package.json +++ b/packages/sac-sdk/package.json @@ -9,10 +9,13 @@ "build": "tsc" }, "dependencies": { - "@stellar/stellar-sdk": "^14.2.0", + "@stellar/stellar-sdk": "16.0.0-rc.1", "buffer": "^6.0.3" }, "devDependencies": { "typescript": "^5.9.2" + }, + "engines": { + "node": ">=22.0.0" } } diff --git a/packages/sac-sdk/src/index.ts b/packages/sac-sdk/src/index.ts index fe60b54..af5697d 100644 --- a/packages/sac-sdk/src/index.ts +++ b/packages/sac-sdk/src/index.ts @@ -5,11 +5,11 @@ import { ClientOptions as ContractClientOptions, MethodOptions, Spec as ContractSpec, -} from '@stellar/stellar-sdk/minimal/contract'; +} from '@stellar/stellar-sdk/contract'; import type { u32, i128, -} from '@stellar/stellar-sdk/minimal/contract'; +} from '@stellar/stellar-sdk/contract'; if (typeof window !== 'undefined') { //@ts-ignore Buffer exists diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e14f8ca..6a16532 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@stellar/stellar-sdk': ^14.2.0 + '@stellar/stellar-sdk': 16.0.0-rc.1 importers: @@ -18,8 +18,8 @@ importers: specifier: ^13.2.0 version: 13.2.0 '@stellar/stellar-sdk': - specifier: ^14.2.0 - version: 14.2.0 + specifier: 16.0.0-rc.1 + version: 16.0.0-rc.1 base64url: specifier: ^3.0.1 version: 3.0.1 @@ -43,8 +43,8 @@ importers: packages/passkey-kit-sdk: dependencies: '@stellar/stellar-sdk': - specifier: ^14.2.0 - version: 14.2.0 + specifier: 16.0.0-rc.1 + version: 16.0.0-rc.1 buffer: specifier: ^6.0.3 version: 6.0.3 @@ -56,8 +56,8 @@ importers: packages/sac-sdk: dependencies: '@stellar/stellar-sdk': - specifier: ^14.2.0 - version: 14.2.0 + specifier: 16.0.0-rc.1 + version: 16.0.0-rc.1 buffer: specifier: ^6.0.3 version: 6.0.3 @@ -74,13 +74,12 @@ packages: '@actions/io@1.1.3': resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} - '@noble/curves@1.9.7': - resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} - engines: {node: ^14.21.3 || >=16} + '@noble/ed25519@3.1.0': + resolution: {integrity: sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==} - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} '@openzeppelin/relayer-plugin-channels@0.5.0': resolution: {integrity: sha512-mn8TklTFwwqKVVOjYQtJGGX/8IKJwg4hbwfAsz7kvb5L5EhjJzwCjgfT84TL6ZVmHb7DoQ6h2lPgNussDiHy5g==} @@ -93,33 +92,31 @@ packages: '@simplewebauthn/browser@13.2.0': resolution: {integrity: sha512-N3fuA1AAnTo5gCStYoIoiasPccC+xPLx2YU88Dv0GeAmPQTWHETlZQq5xZ0DgUq1H9loXMWQH5qqUjcI7BHJ1A==} - '@stellar/js-xdr@3.1.2': - resolution: {integrity: sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==} + '@stellar/js-xdr@4.0.0': + resolution: {integrity: sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==} + engines: {node: '>=20.0.0', pnpm: '>=9.0.0'} - '@stellar/stellar-base@14.0.1': - resolution: {integrity: sha512-mI6Kjh9hGWDA1APawQTtCbR7702dNT/8Te1uuRFPqqdoAKBk3WpXOQI3ZSZO+5olW7BSHpmVG5KBPZpIpQxIvw==} - engines: {node: '>=20.0.0'} - - '@stellar/stellar-sdk@14.2.0': - resolution: {integrity: sha512-7nh2ogzLRMhfkIC0fGjn1LHUzk3jqVw8tjAuTt5ADWfL9CSGBL18ILucE9igz2L/RU2AZgeAvhujAnW91Ut/oQ==} - engines: {node: '>=20.0.0'} + '@stellar/stellar-sdk@16.0.0-rc.1': + resolution: {integrity: sha512-mkOQUsFgepFIxRFJLgjkLAkYQDBdNKgLpMM5yIWQcCUoXavb0gO1Dlw/hPqVg2KBKUKaXs2ljVLuB4x6bSWR0g==} + engines: {node: '>=22.0.0'} + hasBin: true '@types/node@24.6.0': resolution: {integrity: sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - axios@1.12.2: - resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} - axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + base32.js@0.1.0: resolution: {integrity: sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==} engines: {node: '>=0.12.0'} @@ -131,8 +128,8 @@ packages: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} - bignumber.js@9.3.1: - resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bignumber.js@11.1.3: + resolution: {integrity: sha512-+esZiNSo6VgFokTsYX6mYqNJfFd/IczzZCd4Z7cR8e+AQWhvIcj6nqQ1h9814D9u/TApU0jjTVmfWL0Pd1ZBdA==} buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -141,21 +138,22 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} @@ -181,9 +179,13 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - eventsource@2.0.2: - resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} - engines: {node: '>=12.0.0'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@4.1.0: + resolution: {integrity: sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==} + engines: {node: '>=20.0.0'} feaxios@0.0.23: resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} @@ -197,14 +199,23 @@ packages: debug: optional: true - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true form-data@4.0.4: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -220,9 +231,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -235,27 +243,17 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - is-retry-allowed@3.0.0: resolution: {integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==} engines: {node: '>=12'} - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -268,54 +266,32 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - sha.js@2.4.12: - resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} - engines: {node: '>= 0.10'} - hasBin: true - - to-buffer@1.2.1: - resolution: {integrity: sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==} - engines: {node: '>= 0.4'} - - toml@3.0.0: - resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + undici-types@7.13.0: resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==} - urijs@1.19.11: - resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} - - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} - engines: {node: '>= 0.4'} - snapshots: '@actions/exec@1.1.1': @@ -324,20 +300,19 @@ snapshots: '@actions/io@1.1.3': {} - '@noble/curves@1.9.7': - dependencies: - '@noble/hashes': 1.8.0 + '@noble/ed25519@3.1.0': {} - '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} '@openzeppelin/relayer-plugin-channels@0.5.0': dependencies: '@actions/exec': 1.1.1 '@openzeppelin/relayer-sdk': 1.8.0 - '@stellar/stellar-sdk': 14.2.0 + '@stellar/stellar-sdk': 16.0.0-rc.1 axios: 1.13.2 transitivePeerDependencies: - debug + - supports-color '@openzeppelin/relayer-sdk@1.8.0': dependencies: @@ -347,41 +322,39 @@ snapshots: '@simplewebauthn/browser@13.2.0': {} - '@stellar/js-xdr@3.1.2': {} + '@stellar/js-xdr@4.0.0': {} - '@stellar/stellar-base@14.0.1': + '@stellar/stellar-sdk@16.0.0-rc.1': dependencies: - '@noble/curves': 1.9.7 - '@stellar/js-xdr': 3.1.2 + '@noble/ed25519': 3.1.0 + '@noble/hashes': 2.2.0 + '@stellar/js-xdr': 4.0.0 + axios: 1.16.1 base32.js: 0.1.0 - bignumber.js: 9.3.1 + bignumber.js: 11.1.3 buffer: 6.0.3 - sha.js: 2.4.12 - - '@stellar/stellar-sdk@14.2.0': - dependencies: - '@stellar/stellar-base': 14.0.1 - axios: 1.12.2 - bignumber.js: 9.3.1 - eventsource: 2.0.2 + commander: 14.0.3 + eventsource: 4.1.0 feaxios: 0.0.23 - randombytes: 2.1.0 - toml: 3.0.0 - urijs: 1.19.11 + smol-toml: 1.6.1 + uint8array-extras: 1.5.0 transitivePeerDependencies: - debug + - supports-color '@types/node@24.6.0': dependencies: undici-types: 7.13.0 - asynckit@0.4.0: {} - - available-typed-arrays@1.0.7: + agent-base@6.0.2: dependencies: - possible-typed-array-names: 1.1.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color - axios@1.12.2: + asynckit@0.4.0: {} + + axios@1.13.2: dependencies: follow-redirects: 1.15.9 form-data: 4.0.4 @@ -389,13 +362,15 @@ snapshots: transitivePeerDependencies: - debug - axios@1.13.2: + axios@1.16.1: dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.4 - proxy-from-env: 1.1.0 + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color base32.js@0.1.0: {} @@ -403,7 +378,7 @@ snapshots: base64url@3.0.1: {} - bignumber.js@9.3.1: {} + bignumber.js@11.1.3: {} buffer@6.0.3: dependencies: @@ -415,27 +390,15 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 - define-data-property@1.1.4: + commander@14.0.3: {} + + debug@4.4.3: dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 + ms: 2.1.3 delayed-stream@1.0.0: {} @@ -460,7 +423,11 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - eventsource@2.0.2: {} + eventsource-parser@3.1.0: {} + + eventsource@4.1.0: + dependencies: + eventsource-parser: 3.1.0 feaxios@0.0.23: dependencies: @@ -468,9 +435,7 @@ snapshots: follow-redirects@1.15.9: {} - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 + follow-redirects@1.16.0: {} form-data@4.0.4: dependencies: @@ -480,6 +445,14 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + function-bind@1.1.2: {} get-intrinsic@1.3.0: @@ -502,10 +475,6 @@ snapshots: gopd@1.2.0: {} - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -516,20 +485,17 @@ snapshots: dependencies: function-bind: 1.1.2 - ieee754@1.2.1: {} - - inherits@2.0.4: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color - is-callable@1.2.7: {} + ieee754@1.2.1: {} is-retry-allowed@3.0.0: {} - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.19 - - isarray@2.0.5: {} - math-intrinsics@1.1.0: {} mime-db@1.52.0: {} @@ -538,57 +504,16 @@ snapshots: dependencies: mime-db: 1.52.0 - possible-typed-array-names@1.1.0: {} + ms@2.1.3: {} proxy-from-env@1.1.0: {} - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - - safe-buffer@5.2.1: {} - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - sha.js@2.4.12: - dependencies: - inherits: 2.0.4 - safe-buffer: 5.2.1 - to-buffer: 1.2.1 - - to-buffer@1.2.1: - dependencies: - isarray: 2.0.5 - safe-buffer: 5.2.1 - typed-array-buffer: 1.0.3 - - toml@3.0.0: {} + proxy-from-env@2.1.0: {} - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 + smol-toml@1.6.1: {} typescript@5.9.2: {} - undici-types@7.13.0: {} - - urijs@1.19.11: {} + uint8array-extras@1.5.0: {} - which-typed-array@1.1.19: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 + undici-types@7.13.0: {} diff --git a/src/base.ts b/src/base.ts index c1b0821..c513df1 100644 --- a/src/base.ts +++ b/src/base.ts @@ -1,4 +1,4 @@ -import { Server } from "@stellar/stellar-sdk/minimal/rpc" +import { Server } from "@stellar/stellar-sdk/rpc" // TODO consider adding support for a signAuthEntry method that conforms to the ed25519 signature scheme of this passkey interface // once we do that we can clean the code a little with the `xdr.HashIdPreimage.envelopeTypeSorobanAuthorization` stuff diff --git a/src/kit.ts b/src/kit.ts index 7653524..f8ecfea 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -1,12 +1,13 @@ import { Client as PasskeyClient, type Signature, type SignerKey as SDKSignerKey, type SignerLimits as SDKSignerLimits } from 'passkey-kit-sdk' -import { StrKey, hash, xdr, Keypair, Address, TransactionBuilder, Operation } from '@stellar/stellar-sdk/minimal' +import * as StellarSdk from '@stellar/stellar-sdk' +import { StrKey, hash, xdr, Keypair, Address, TransactionBuilder, Operation } from '@stellar/stellar-sdk' import { startRegistration, startAuthentication, type AuthenticationResponseJSON, type AuthenticatorAttestationResponseJSON, type AuthenticatorSelectionCriteria } from "@simplewebauthn/browser" import { Buffer } from 'buffer' import base64url from 'base64url' import type { SignerKey, SignerLimits, SignerStore } from './types' import { PasskeyBase } from './base' -import { AssembledTransaction, basicNodeSigner, type AssembledTransactionOptions, type Tx } from '@stellar/stellar-sdk/minimal/contract' -import type { Server } from '@stellar/stellar-sdk/minimal/rpc' +import { AssembledTransaction, basicNodeSigner, type AssembledTransactionOptions, type Spec as ContractSpec, type Tx } from '@stellar/stellar-sdk/contract' +import type { Server } from '@stellar/stellar-sdk/rpc' type P27HashIdPreimage = typeof xdr.HashIdPreimage & { envelopeTypeSorobanAuthorizationWithAddress?: (value: unknown) => xdr.HashIdPreimage @@ -22,6 +23,18 @@ type P27Xdr = typeof xdr & { }) => unknown } +type P27AuthSdk = typeof StellarSdk & { + buildAuthorizationEntryPreimage?: ( + entry: xdr.SorobanAuthorizationEntry, + validUntilLedgerSeq: number, + networkPassphrase: string + ) => xdr.HashIdPreimage +} + +type PasskeyClientWithSpec = PasskeyClient & { + spec: ContractSpec +} + export function getAddressCredentials(credentials: xdr.SorobanCredentials): xdr.SorobanAddressCredentials { const typedCredentials = credentials as unknown as { address: () => xdr.SorobanAddressCredentials @@ -73,6 +86,16 @@ export function buildSignaturePayload( assertSignatureExpirationLedger(expiration) const credentials = getAddressCredentials(entry.credentials()) + const sdkAuth = StellarSdk as P27AuthSdk + + if (sdkAuth.buildAuthorizationEntryPreimage) { + credentials.signatureExpirationLedger(expiration) + return hash(sdkAuth.buildAuthorizationEntryPreimage( + entry, + expiration, + networkPassphrase + ).toXDR()) + } if (usesAddressBoundPayload(entry.credentials())) { const hashIdPreimage = xdr.HashIdPreimage as P27HashIdPreimage @@ -131,6 +154,10 @@ export class PasskeyKit extends PasskeyBase { public networkPassphrase: string public wallet: PasskeyClient | undefined + private get walletSpec(): ContractSpec { + return (this.wallet as PasskeyClientWithSpec).spec + } + constructor(options: { rpcUrl: string, networkPassphrase: string, @@ -465,8 +492,8 @@ export class PasskeyKit extends PasskeyBase { const scValType = xdr.ScSpecTypeDef.scSpecTypeUdt( new xdr.ScSpecTypeUdt({ name: "Signature" }), ); - const scKey = this.wallet!.spec.nativeToScVal(key, scKeyType); - const scVal = val ? this.wallet!.spec.nativeToScVal(val, scValType) : xdr.ScVal.scvVoid(); + const scKey = this.walletSpec.nativeToScVal(key, scKeyType); + const scVal = val ? this.walletSpec.nativeToScVal(val, scValType) : xdr.ScVal.scvVoid(); const scEntry = new xdr.ScMapEntry({ key: scKey, val: scVal, @@ -532,7 +559,7 @@ export class PasskeyKit extends PasskeyBase { ) { if (!(txn instanceof AssembledTransaction)) { try { - txn = AssembledTransaction.fromXDR(this.wallet!.options, typeof txn === 'string' ? txn : txn.toXDR(), this.wallet!.spec); + txn = AssembledTransaction.fromXDR(this.wallet!.options, typeof txn === 'string' ? txn : txn.toXDR(), this.walletSpec); } catch { if (!(txn instanceof AssembledTransaction)) { const built = TransactionBuilder.fromXDR(typeof txn === 'string' ? txn : txn.toXDR(), this.networkPassphrase); diff --git a/src/sac.ts b/src/sac.ts index 63f3f10..8bb04ea 100644 --- a/src/sac.ts +++ b/src/sac.ts @@ -1,6 +1,6 @@ import { Client as SacClient } from 'sac-sdk' import { PasskeyBase } from "./base" -import type { Server } from '@stellar/stellar-sdk/minimal/rpc' +import type { Server } from '@stellar/stellar-sdk/rpc' export class SACClient extends PasskeyBase { declare rpc: Server diff --git a/src/server.ts b/src/server.ts index be4e2aa..7e99dec 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,10 +1,10 @@ -import { xdr } from "@stellar/stellar-sdk/minimal" +import { xdr } from "@stellar/stellar-sdk" import { PasskeyBase } from "./base" import base64url from "base64url" -import type { Tx } from "@stellar/stellar-sdk/minimal/contract" +import type { Tx } from "@stellar/stellar-sdk/contract" import type { Signer } from "./types" -import { AssembledTransaction } from "@stellar/stellar-sdk/minimal/contract" -import { Durability } from "@stellar/stellar-sdk/minimal/rpc" +import { AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { Durability } from "@stellar/stellar-sdk/rpc" import { ChannelsClient } from "@openzeppelin/relayer-plugin-channels" export class PasskeyServer extends PasskeyBase { From 51c3908325b26fef88fa58998311a57595308cfc Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 20:33:03 -0400 Subject: [PATCH 03/30] chore: version bumps for stellar sdk v16 rc prep Co-Authored-By: Claude Fable 5 --- package.json | 2 +- packages/passkey-kit-sdk/package.json | 2 +- packages/sac-sdk/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 8014f29..ef0fda2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passkey-kit", - "version": "0.12.0", + "version": "0.12.1", "description": "A helper library for creating and using smart wallet accounts on the Stellar blockchain.", "author": "Tyler van der Hoeven", "license": "MIT", diff --git a/packages/passkey-kit-sdk/package.json b/packages/passkey-kit-sdk/package.json index 7de64a8..7278770 100644 --- a/packages/passkey-kit-sdk/package.json +++ b/packages/passkey-kit-sdk/package.json @@ -1,5 +1,5 @@ { - "version": "0.7.2", + "version": "0.7.3", "name": "passkey-kit-sdk", "type": "module", "main": "src/index.ts", diff --git a/packages/sac-sdk/package.json b/packages/sac-sdk/package.json index 0d89bc3..17d46d3 100644 --- a/packages/sac-sdk/package.json +++ b/packages/sac-sdk/package.json @@ -1,5 +1,5 @@ { - "version": "0.4.2", + "version": "0.4.3", "name": "sac-sdk", "type": "module", "main": "src/index.ts", From acefc987e3c5a323246553a50df3961ce526e92e Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 20:33:03 -0400 Subject: [PATCH 04/30] docs: add ground-up overhaul mission prompt (TOPROMPT.md) Co-Authored-By: Claude Fable 5 --- TOPROMPT.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 TOPROMPT.md diff --git a/TOPROMPT.md b/TOPROMPT.md new file mode 100644 index 0000000..eca12bf --- /dev/null +++ b/TOPROMPT.md @@ -0,0 +1,79 @@ +# passkey-kit ground-up overhaul — mission prompt + +Do a full and comprehensive audit and then a complete, forward-only rework of this entire repository — contracts, SDK, bindings, indexing, demo, and docs. This is the passkey-kit equivalent of the smart-account-kit v0.4.0 OZ-parity overhaul we ran on 2026-07-10 (Solo project 34; transcripts in `~/.claude/projects/-Users-kalepail-Desktop-Web-Soroban-OpenZeppelin-smart-account-kit/`). Use that repo and that session's playbook as inspiration and guidance — but this repo has its OWN smart account contract, which raises the bar on contract correctness (see the audit gate below). + +Treat nothing as precious. Absolutely NO backwards compatibility. All work is forward-only, ground-up redesign as/if needed. Every line of code must earn its keep. + +## Reference material + +- Sibling repo (inspiration, not a dependency): `/Users/kalepail/Desktop/Web/Soroban/OpenZeppelin/smart-account-kit` — study its typed error model (ContractError registry, discriminated TransactionResult), signer abstractions (PasskeySigner/Ed25519Signer/DelegatedSigner), typed policy clients, client-side limit validation, bindings verify pipeline (`verify:bindings`), e2e scripts (`scripts/browser-full-e2e-audit.sh`, `scripts/testnet-passkey-smoke.sh`, `scripts/agent-browser-webauthn-helper.mjs`), `docs/migration-v0.4.0.md`, and CHANGELOG discipline. +- Live passkey testing: the agent-browser CLI + the agent-browser-webauthn skill — https://github.com/kalepail/skills/blob/main/skills/agent-browser-webauthn/SKILL.md +- OZ Relayer docs: https://docs.openzeppelin.com/relayer/1.5.x/guides/stellar-channels-guide, https://docs.openzeppelin.com/relayer/stellar, https://docs.openzeppelin.com/relayer/1.5.x/guides/stellar-sponsored-transactions-guide +- Stellar Indexer: https://stellarindexer.com/ — API at `https://api.stellarindexer.com` (OpenAPI: `/openapi-doc`), JS SDK `@stellar-indexer/stellar-indexer-sdk` (JSR, Creit Tech). One live endpoint: `POST /v1/contract-data/` (1–25 contract IDs per call, key/val JSON predicates, temporary + persistent durability, tombstoned `deleted_at` entries, Bearer JWT auth). +- Use perplexity MCP and parallel MCP/CLI for research as needed while you work. + +## Service architecture (get this right from the start) + +These are distinct concerns — do not conflate them: + +- **Relayer (OZ) = transaction submission** (fee sponsorship via the channels plugin, `@openzeppelin/relayer-plugin-channels`, already a dependency). Launchtube is fully legacy — purge every remaining reference. Actually VERIFY the Relayer works, on both testnet and mainnet — you can generate and use API keys for that service (see the OZ guides above). Do not claim relayer coverage from unit tests alone. +- **Mercury = indexer** (the `zephyr/` program is Mercury's Zephyr framework). JWTs are in the repo-root `.env` as `MERCURY_TESTNET_JWT` / `MERCURY_MAINNET_JWT`. +- **Stellar Indexer = second indexer backend** (generic contract-data indexer, closed beta). Access token in root `.env` as `STELLAR_INDEXER_ACCESS_TOKEN`. + +Secrets rule: use the `.env` values via shell substitution only — NEVER print, log, commit, or paste token values into terminal output, todo comments, or scratchpads. If a token is missing, a service is down, or RPC breaks, ask the user and keep going on other work — don't guess or stall. + +## Scope of the rework + +**1. Contracts (`contracts/smart-wallet`, `smart-wallet-interface`, `sample-policy`, `example-contract`) — very careful.** +This is a wallet that holds real funds; correctness is non-negotiable. Update to current soroban-sdk / Protocol 27+, rework deliberately: storage layout, signer model (secp256r1 passkeys, ed25519, policy signers), auth/`__check_auth` semantics, WebAuthn verification (`verify.rs`, `base64_url.rs`), event schema designed hand-in-glove with the indexers (the `sw_v1` topic scheme is renegotiable — design events FOR indexing), upgradeability story made explicit, and the factory/deploy path (deterministic contract IDs derived from the passkey keyId — preserve that property; the indexer designs depend on it). Deployed legacy wallets are legacy: no compat shims, but record the canonical WASM hashes and deployment story in docs. + +**2. SDK (`src/`: base.ts, kit.ts, sac.ts, server.ts, types.ts).** +Ground-up rework against `@stellar/stellar-sdk` v16 (move off the rc as stable allows). Pull the SDK in WELL: audit every place we hand-roll something the stellar-sdk already provides (preimage building, nonce generation, ScVal handling, assembled-transaction plumbing) and use the SDK instead — don't rewrite what it ships. Adopt the smart-account-kit patterns that earned their keep: typed errors + contract-error decoding, discriminated success/failure results, a unified signing pipeline, client-side validation of contract limits, clean separation of client (`PasskeyKit`) vs server (`PasskeyServer`) concerns. Kill the checked-in `types/` dir if the build should generate it. Evaluate whether `sac.ts`/`sac-sdk` still earn their keep. + +**3. Bindings (`packages/passkey-kit-sdk`, `packages/sac-sdk`).** +Regenerate from the final canonical WASM builds; add a drift guard (a `verify:bindings`-style script) so published bindings provably match the deployed contract hash. + +**4. Indexing — Mercury AND Stellar Indexer, both correct, both verified.** +Design one indexer abstraction in the SDK with two interchangeable backends: +- Mercury: rewrite the `zephyr/` program against the reworked contract's events/storage; ensure add/update/remove signer flows index correctly, including reverse lookup (keyId → wallet) and full signer enumeration. +- Stellar Indexer: implement a backend over `POST /v1/contract-data/` using key/val predicates against the wallet's storage entries (contract IDs are derivable from keyId, so the 25-contract-per-call cap is workable; note tombstoned entries let you see removed signers). +Verify BOTH end-to-end on testnet with live data — a signer added through the demo must be discoverable through each backend, asserted against real responses, before either is called done. + +**5. Demo (`demo/`, Svelte).** +Rebuild on the new API so it exercises EVERY feature: passkey create/connect, ed25519 + policy signers, add/update/remove signers, transfers, SAC interactions, Relayer-sponsored submission for all transactions, and signer discovery via the indexer backends. Modernize the stack as warranted (Svelte 5 / current Vite). Deploy it live (`wrangler pages deploy` is already wired), and once live, add the demo URL to the GitHub repo description. + +**6. Cleanup — evaluate carefully, then cut.** +Candidates: `cheatsheet.txt`, `PROPOSAL.md`, `.cursorrules`, `clone-js-sdk.sh`, `.wrangler/`, `.DS_Store`, checked-in `types/`, the numbered `bun_tests/` (fold what's valuable into a real test suite), stale Launchtube references, `contracts/out` artifacts. For each: carefully evaluate whether it's actually worth dropping before doing so — cruft dies, load-bearing stays. Delete this TOPROMPT.md at the end too. + +**7. Tests and docs.** +Excellent, detailed tests for full coverage: Rust unit + integration tests for the contracts (auth test vectors for WebAuthn verification and signature payloads; fuzz/property tests where they pay), TypeScript tests for the SDK (a real runner replacing the ad-hoc bun scripts), e2e scripts for the live flows. Improve and maintain docs as you go: rewritten README verified against the ACTUAL final API (no aspirational docs), CHANGELOG, and a migration guide (old → new) with an explicit A/B gap analysis — anything the old version had that the new one doesn't but should? All good, clean, and fully featured? + +## Methodology and model allocation + +- Plan in Fable (this session orchestrates). Make the plan here, then execute with Solo subagents. +- Do most of the coding/work in Fable and Opus subagents (Solo `spawn_agent`; use `solo help` progressively and iteratively to manage the work). +- Reserve **gpt-5.6-sol and terra** for review, assurance, and independent adversarial review — especially the contract audit gate below. They review; they don't author. +- Solo protocol (proven last time): canonical specs + master plan in scratchpads (locked design decisions subagents must follow); todos tagged per wave with bodies as full specs; agents own explicit path scopes that never overlap; commits under a `git-ops` lock staging ONLY owned paths (never `git add -A`); tests+build green before every commit — never commit red; orchestrator check-in timers watching for idle/stalled agents; `/clear` + re-brief agents whose context balloons. +- Git: current branch is `codex/protocol-27-auth-prep` (uncommitted version-bump changes) with default branch `next` — reconcile that state first, then do the overhaul on one dedicated branch. Never push/publish/deploy from subagents; the orchestrator gates all outward-facing steps. + +## Contract audit gate (hard requirement) + +The reworked contracts do not ship until they pass independent audits by BOTH Fable and gpt-5.6-sol — full audits covering auth semantics, signature verification, storage/TTL handling, policy-signer limits, upgrade paths, and event correctness — plus adversarial review from terra. Each auditor works independently against the frozen contract source + spec scratchpad, files findings as todos, and every confirmed finding gets fixed and re-verified by the finder. Add the code-review skill at high effort over the full branch diff as the final static gate. Guarantee correctness; don't assert it. + +## Verification (before anything ships) + +1. Static: full test suite + all builds green (SDK, bindings, contracts, zephyr, demo). +2. Live browser passkey e2e on testnet using agent-browser + the WebAuthn skill: create → reconnect → add/remove signers → Relayer-sponsored transfer → indexer discovery through BOTH backends. Assert REAL outputs — `C...` contract IDs, tx hashes, explicit errors. Never treat a click as success. Fresh agent-browser sessions per run; close sessions when done — do not leave fleets of idle Chrome-for-Testing processes. +3. Verify against the CANONICAL contract build — pin and cross-check the WASM hash in every env/config so no stale-hash drift invalidates the evidence (this bit us last time). +4. Relayer verified live on testnet and mainnet; Mercury and Stellar Indexer verified live with keys from `.env`. + +## Endgame (in order, orchestrator-led) + +1. CHANGELOG + migration guide + README finalized; A/B gap analysis written up. +2. `/secret-scanning`, then `/security-review`; fix anything found. +3. Consolidate: all branches closed back to a single default branch, no worktrees, stashes, stale PRs, or leftover branches locally or remotely; close any GitHub issues the overhaul resolves. +4. Publish to npm — propose the version bump (0.13.0 vs 1.0.0) and ask for the OTP when ready. Never publish without it. +5. Deploy the demo live; add the live URL to the repo description. +6. Process hygiene: close idle subagents and browser sessions, archive completed Solo scratchpads/todos, delete TOPROMPT.md. + +As or if you have any questions, ask. Otherwise: plan, brief me on the plan, and go. From 612b85528109efc6d5f937694995cc63b639fb1a Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 21:17:49 -0400 Subject: [PATCH 05/30] =?UTF-8?q?B1=20SDK:=20foundations=20=E2=80=94=20typ?= =?UTF-8?q?ed=20errors,=20storage,=20events,=20packaging,=20vitest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ground-up SDK foundations (todo 948, overhaul/ground-up): - errors.ts: PasskeyKitError + ranged code enum; ContractError registry (contract-errors.ts, current 9 codes) + diagnostic decode + bindings-sync test - types.ts: discriminated TransactionResult, CreateWalletResult/ ConnectWalletResult, StoredPasskey/StorageAdapter; rename Mercury-row Signer -> IndexedSigner (kills bindings Signer collision) - utils.ts: compactSignature, extractPublicKeyFromAttestation (SPKI path), generateChallenge (random 32B), deriveContractAddress (+ golden vectors) - validation.ts, base64url.ts shim, events.ts typed emitter, constants.ts, version.ts (+ scripts/sync-version.js) - storage/: Memory/LocalStorage/IndexedDB adapters (passkey-kit/storage subpath) - kit/auth-payload.ts: SDK-backed P27 preimage helpers (ports bun_tests/9) - Packaging: compile to dist/ (ESM+d.ts), exports map (. /storage /server), files whitelist, sideEffects false, NodeNext; stellar-sdk 16.0.1 dep + peer >=16.0.0; relayer-plugin-channels ^0.20; verify-esm.mjs gate; bindings ship compiled JS dist - Vitest (70 tests) replaces bun_tests/; delete bun_tests/, clone-js-sdk.sh pnpm build (+ verify-esm) and vitest green. Old kit.ts/server.ts kept compiling under NodeNext; full rewrites land in B2/B3. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 +- .npmignore | 189 ----- bun_tests/.gitignore | 175 ---- bun_tests/0.index.ts | 39 - bun_tests/1.index.ts | 9 - bun_tests/2.index.ts | 39 - bun_tests/3.index.ts | 13 - bun_tests/4.index.ts | 18 - bun_tests/5.index.ts | 16 - bun_tests/6.index.ts | 3 - bun_tests/7.index.ts | 26 - bun_tests/8.index.ts | 6 - bun_tests/9.index.ts | 176 ---- bun_tests/README.md | 15 - bun_tests/bun.lockb | Bin 3439 -> 0 bytes bun_tests/package.json | 12 - bun_tests/tsconfig.json | 27 - clone-js-sdk.sh | 21 - package.json | 64 +- packages/passkey-kit-sdk/package.json | 21 +- packages/passkey-kit-sdk/tsconfig.json | 108 +-- packages/sac-sdk/package.json | 21 +- packages/sac-sdk/tsconfig.json | 108 +-- pnpm-lock.yaml | 1066 ++++++++++++++++++++++-- scripts/sync-version.js | 33 + scripts/verify-esm.mjs | 58 ++ src/base64url.ts | 19 + src/constants.ts | 103 +++ src/contract-errors.test.ts | 139 +++ src/contract-errors.ts | 163 ++++ src/errors.test.ts | 91 ++ src/errors.ts | 346 ++++++++ src/events.test.ts | 69 ++ src/events.ts | 169 ++++ src/index.ts | 94 ++- src/kit.ts | 6 +- src/kit/auth-payload.test.ts | 188 +++++ src/kit/auth-payload.ts | 107 +++ src/sac.ts | 2 +- src/server.ts | 8 +- src/storage/index.ts | 14 + src/storage/indexeddb.test.ts | 68 ++ src/storage/indexeddb.ts | 143 ++++ src/storage/localStorage.test.ts | 75 ++ src/storage/localStorage.ts | 114 +++ src/storage/memory.test.ts | 58 ++ src/storage/memory.ts | 53 ++ src/types.ts | 229 ++++- src/utils.test.ts | 122 +++ src/utils.ts | 216 +++++ src/validation.test.ts | 87 ++ src/validation.ts | 121 +++ src/version.ts | 10 + tsconfig.json | 39 +- 54 files changed, 3999 insertions(+), 1121 deletions(-) delete mode 100644 .npmignore delete mode 100644 bun_tests/.gitignore delete mode 100644 bun_tests/0.index.ts delete mode 100644 bun_tests/1.index.ts delete mode 100644 bun_tests/2.index.ts delete mode 100644 bun_tests/3.index.ts delete mode 100644 bun_tests/4.index.ts delete mode 100644 bun_tests/5.index.ts delete mode 100644 bun_tests/6.index.ts delete mode 100644 bun_tests/7.index.ts delete mode 100644 bun_tests/8.index.ts delete mode 100644 bun_tests/9.index.ts delete mode 100644 bun_tests/README.md delete mode 100755 bun_tests/bun.lockb delete mode 100644 bun_tests/package.json delete mode 100644 bun_tests/tsconfig.json delete mode 100755 clone-js-sdk.sh create mode 100644 scripts/sync-version.js create mode 100644 scripts/verify-esm.mjs create mode 100644 src/base64url.ts create mode 100644 src/constants.ts create mode 100644 src/contract-errors.test.ts create mode 100644 src/contract-errors.ts create mode 100644 src/errors.test.ts create mode 100644 src/errors.ts create mode 100644 src/events.test.ts create mode 100644 src/events.ts create mode 100644 src/kit/auth-payload.test.ts create mode 100644 src/kit/auth-payload.ts create mode 100644 src/storage/index.ts create mode 100644 src/storage/indexeddb.test.ts create mode 100644 src/storage/indexeddb.ts create mode 100644 src/storage/localStorage.test.ts create mode 100644 src/storage/localStorage.ts create mode 100644 src/storage/memory.test.ts create mode 100644 src/storage/memory.ts create mode 100644 src/utils.test.ts create mode 100644 src/utils.ts create mode 100644 src/validation.test.ts create mode 100644 src/validation.ts create mode 100644 src/version.ts diff --git a/.gitignore b/.gitignore index 923929a..b9520a2 100644 --- a/.gitignore +++ b/.gitignore @@ -118,7 +118,9 @@ out # Nuxt.js build / generate output .nuxt -# dist + +# Compiled SDK / bindings output (built at publish time) +dist/ # Gatsby files diff --git a/.npmignore b/.npmignore deleted file mode 100644 index b054df8..0000000 --- a/.npmignore +++ /dev/null @@ -1,189 +0,0 @@ -# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore - -# Logs - -logs -_.log -npm-debug.log_ -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Caches - -.cache - -# Diagnostic reports (https://nodejs.org/api/report.html) - -report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json - -# Runtime data - -pids -_.pid -_.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover - -lib-cov - -# Coverage directory used by tools like istanbul - -coverage -*.lcov - -# nyc test coverage - -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) - -.grunt - -# Bower dependency directory (https://bower.io/) - -bower_components - -# node-waf configuration - -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) - -build/Release - -# Dependency directories - -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) - -web_modules/ - -# TypeScript cache - -*.tsbuildinfo - -# Optional npm cache directory - -.npm - -# Optional eslint cache - -.eslintcache - -# Optional stylelint cache - -.stylelintcache - -# Microbundle cache - -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history - -.node_repl_history - -# Output of 'npm pack' - -*.tgz - -# Yarn Integrity file - -.yarn-integrity - -# dotenv environment variable files - -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) - -.parcel-cache - -# Next.js build output - -.next -out - -# Nuxt.js build / generate output - -.nuxt -dist - -# Gatsby files - -# Comment in the public line in if your project uses Gatsby and not Next.js - -# https://nextjs.org/blog/next-9-1#public-directory-support - -# public - -# vuepress build output - -.vuepress/dist - -# vuepress v2.x temp and cache directory - -.temp - -# Docusaurus cache and generated files - -.docusaurus - -# Serverless directories - -.serverless/ - -# FuseBox cache - -.fusebox/ - -# DynamoDB Local files - -.dynamodb/ - -# TernJS port file - -.tern-port - -# Stores VSCode versions used for testing VSCode extensions - -.vscode-test - -# yarn v2 - -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -# IntelliJ based IDEs -.idea - -# Finder (MacOS) folder config -.DS_Store - -meta.json - -package - -bun_tests/ -contracts/ -demo/ -ext/ -zephyr/ - -# TODO can we toss packages as well? -# Have more to toss here as well. Ideally this was allow list vs block list -# I think there's things we can do in the package.json to help with this. "exports", maybe, etc. \ No newline at end of file diff --git a/bun_tests/.gitignore b/bun_tests/.gitignore deleted file mode 100644 index 9b1ee42..0000000 --- a/bun_tests/.gitignore +++ /dev/null @@ -1,175 +0,0 @@ -# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore - -# Logs - -logs -_.log -npm-debug.log_ -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Caches - -.cache - -# Diagnostic reports (https://nodejs.org/api/report.html) - -report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json - -# Runtime data - -pids -_.pid -_.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover - -lib-cov - -# Coverage directory used by tools like istanbul - -coverage -*.lcov - -# nyc test coverage - -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) - -.grunt - -# Bower dependency directory (https://bower.io/) - -bower_components - -# node-waf configuration - -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) - -build/Release - -# Dependency directories - -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) - -web_modules/ - -# TypeScript cache - -*.tsbuildinfo - -# Optional npm cache directory - -.npm - -# Optional eslint cache - -.eslintcache - -# Optional stylelint cache - -.stylelintcache - -# Microbundle cache - -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history - -.node_repl_history - -# Output of 'npm pack' - -*.tgz - -# Yarn Integrity file - -.yarn-integrity - -# dotenv environment variable files - -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) - -.parcel-cache - -# Next.js build output - -.next -out - -# Nuxt.js build / generate output - -.nuxt -dist - -# Gatsby files - -# Comment in the public line in if your project uses Gatsby and not Next.js - -# https://nextjs.org/blog/next-9-1#public-directory-support - -# public - -# vuepress build output - -.vuepress/dist - -# vuepress v2.x temp and cache directory - -.temp - -# Docusaurus cache and generated files - -.docusaurus - -# Serverless directories - -.serverless/ - -# FuseBox cache - -.fusebox/ - -# DynamoDB Local files - -.dynamodb/ - -# TernJS port file - -.tern-port - -# Stores VSCode versions used for testing VSCode extensions - -.vscode-test - -# yarn v2 - -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -# IntelliJ based IDEs -.idea - -# Finder (MacOS) folder config -.DS_Store diff --git a/bun_tests/0.index.ts b/bun_tests/0.index.ts deleted file mode 100644 index 5bcbecc..0000000 --- a/bun_tests/0.index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Account, nativeToScVal, Operation, StrKey, TransactionBuilder } from '@stellar/stellar-sdk'; -import { Server } from '@stellar/stellar-sdk/rpc'; - -const networkPassphrase = 'Test SDF Network ; September 2015' -const rpcUrl = 'https://soroban-testnet.stellar.org' -const rpc = new Server(rpcUrl) - -const mockPubkey = StrKey.encodeEd25519PublicKey(Buffer.alloc(32)) -const mockSource = new Account(mockPubkey, '0') - -const id = Buffer.from([125,176,117,115,57,12,172,162,174,94,93,239,223,251,112,31,79,213,168,149]) -const pk = Buffer.from([4,145,97,10,184,102,167,68,139,113,167,62,97,52,78,114,175,146,41,171,173,84,128,243,131,159,105,166,51,123,134,42,245,34,106,52,208,45,27,210,224,216,175,242,131,119,135,110,65,212,130,170,215,213,20,100,121,31,108,140,154,13,115,198,184]) - -const transaction = new TransactionBuilder(mockSource, { - fee: '0', - networkPassphrase, -}) -.addOperation(Operation.invokeContractFunction({ - contract: 'CCU4ZFRZXJO4YWOUJDO7PWCYN6YFTMBDYFJFQ6R7SEOLFU5PNJNZA46W', - function: 'deploy', - args: [ - nativeToScVal(id), - nativeToScVal(pk), - ] -})) -.setTimeout(0) -.build() - -console.log(transaction.toXDR()); - -const sim = await rpc._simulateTransaction(transaction) - -console.log(sim); - -// if (!SorobanRpc.Api.isSimulationSuccess(sim)) { -// throw sim -// } - -// console.log(sim); diff --git a/bun_tests/1.index.ts b/bun_tests/1.index.ts deleted file mode 100644 index fdadb93..0000000 --- a/bun_tests/1.index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { xdr } from '@stellar/stellar-sdk'; - -const eventXDR = "AAAAAQAAAAAAAAABnUGtbB9pyBlyh+Q9MYjiiXLowBWO71Mz9WKbJ1z31ZgAAAABAAAAAAAAAAQAAAAPAAAABXN3X3YxAAAAAAAADwAAAANhZGQAAAAADQAAABR1uCpn7oY1jSi/pSfwW2e/YChYLgAAAA8AAAAEaW5pdAAAABAAAAABAAAAAgAAAA0AAABBBBs4FcABSsrQUBdm1u3Li1VPxppm2vqLR8EmaVdtlWI+WVvRnMesQxro9mTiF9Dn14HjKGlPjElWbR6beIsoVcYAAAAAAAAAAAAAAQ==" - -const event = xdr.DiagnosticEvent.fromXDR(eventXDR, 'base64') - -console.log( - event.event().body().v0().data().toXDR().length -); diff --git a/bun_tests/2.index.ts b/bun_tests/2.index.ts deleted file mode 100644 index d8e72ff..0000000 --- a/bun_tests/2.index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import base64url from 'base64url' - -let publicKey = base64url.toBuffer("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIWhAQyE5H-_9WM__87tYZq3yJPQ0rostof00z3MMSMqG3SuBh2TaTUHQDwd4CHyArPQ4EhKoScPbq0zxm1k_Dw") -let authenticatorData = base64url.toBuffer("SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAABAAAAAAAAAAAAAAAAAAAAAAAQA8EYfqizPJVk0HtwVdqazrXmAyb7tuHDD7PzBmf6gFOyCngKjd1RCTIQHCpS3SMyeYULBg7Ykx6n1usOd5PlSilAQIDJiABIVggIWhAQyE5H-_9WM__87tYZq3yJPQ0rostof00z3MMSMoiWCCG3SuBh2TaTUHQDwd4CHyArPQ4EhKoScPbq0zxm1k_Dw") -let attestationObject = base64url.toBuffer("o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAABAAAAAAAAAAAAAAAAAAAAAAAQA8EYfqizPJVk0HtwVdqazrXmAyb7tuHDD7PzBmf6gFOyCngKjd1RCTIQHCpS3SMyeYULBg7Ykx6n1usOd5PlSilAQIDJiABIVggIWhAQyE5H-_9WM__87tYZq3yJPQ0rostof00z3MMSMoiWCCG3SuBh2TaTUHQDwd4CHyArPQ4EhKoScPbq0zxm1k_Dw") - -/// - -let publicK = publicKey.slice(publicKey.length - 65).toString('hex') - -/// - -const credentialIdLength = (authenticatorData[53] << 8) | authenticatorData[54] - -let authenticatorD = Buffer.from([ - 0x04, - ...authenticatorData.slice(65 + credentialIdLength, 97 + credentialIdLength), - ...authenticatorData.slice(100 + credentialIdLength, 132 + credentialIdLength) -]).toString('hex') - -/// - -let publicKeykPrefixSlice = Buffer.from([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20]) -let startIndex = attestationObject.indexOf(publicKeykPrefixSlice) - startIndex = startIndex + publicKeykPrefixSlice.length - -let attestationO = Buffer.from([ - 0x04, - ...attestationObject.slice(startIndex, 32 + startIndex), - ...attestationObject.slice(35 + startIndex, 67 + startIndex) -]).toString('hex') - -/// - -console.log(` - ${publicK} - ${authenticatorD} - ${attestationO} -`) \ No newline at end of file diff --git a/bun_tests/3.index.ts b/bun_tests/3.index.ts deleted file mode 100644 index 0d7c38a..0000000 --- a/bun_tests/3.index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Networks, Operation, Transaction } from '@stellar/stellar-sdk' - -const txn_xdr = 'AAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACZ2gAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAABm9cbgAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAIdHJhbnNmZXIAAAADAAAAEgAAAAHEqqtef1h9hZOQyrCuQRtZ3o8DHtvuSRoq1C8mc+UHHAAAABIAAAABStcRNXctVBwYQExJdAWbrBC3X+B3Ue9QkP4MJ8nhD7UAAAAKAAAAAAAAAAAAAAAAAJiWgAAAAAEAAAABAAAAAcSqq15/WH2Fk5DKsK5BG1nejwMe2+5JGirULyZz5QccVuXL7MuknWEAAkrxAAAAEQAAAAEAAAACAAAAEAAAAAEAAAACAAAADwAAAAdFZDI1NTE5AAAAAA0AAAAgawYrFjEXTyFLVqniIQebbdWimEk+WkHm4UAdwhtQENIAAAAQAAAAAQAAAAIAAAAPAAAAB0VkMjU1MTkAAAAADQAAAEB4a0Pf3RTJS6TOa69dJ7TG5noSSX3OiRRk8xucLNsxgkckcRyeWr18ir8EgFATxz4X2WvJGLMYNDu11ScujcANAAAAEAAAAAEAAAACAAAADwAAAAZQb2xpY3kAAAAAABIAAAABa07R4ERvYHyHhguMb1H1ScVe13IWchSNA5oUEwVRrK8AAAABAAAAAAAAAAHXkotywnA8z+r365/0701QSlWouXn8m0UOoshCtNHOYQAAAAh0cmFuc2ZlcgAAAAMAAAASAAAAAcSqq15/WH2Fk5DKsK5BG1nejwMe2+5JGirULyZz5QccAAAAEgAAAAFK1xE1dy1UHBhATEl0BZusELdf4HdR71CQ/gwnyeEPtQAAAAoAAAAAAAAAAAAAAAAAmJaAAAAAAAAAAAEAAAAAAAAAAQAAAAYAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAUAAAAAQAAAAMAAAAGAAAAAcSqq15/WH2Fk5DKsK5BG1nejwMe2+5JGirULyZz5QccAAAAFVbly+zLpJ1hAAAAAAAAAAYAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAQAAAAAQAAAAIAAAAPAAAAB0JhbGFuY2UAAAAAEgAAAAFK1xE1dy1UHBhATEl0BZusELdf4HdR71CQ/gwnyeEPtQAAAAEAAAAGAAAAAdeSi3LCcDzP6vfrn/TvTVBKVai5efybRQ6iyEK00c5hAAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAABxKqrXn9YfYWTkMqwrkEbWd6PAx7b7kkaKtQvJnPlBxwAAAABAAWL7QAAArgAAAIIAAAAAAACZwQAAAAA' - -const txn = new Transaction(txn_xdr, Networks.TESTNET) - -const op = txn.operations[0] as Operation.InvokeHostFunction - -const sig = op.auth?.[0].credentials().address().signature() - -console.log(sig?.map()?.[0].key().toXDR()); - -console.log(sig?.map()?.[1].key().toXDR()); \ No newline at end of file diff --git a/bun_tests/4.index.ts b/bun_tests/4.index.ts deleted file mode 100644 index 781fdd9..0000000 --- a/bun_tests/4.index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Address, xdr } from '@stellar/stellar-sdk' - -const cred_xdr = 'AAAAAQAAAAF6gVF2E0+pwV5vWTPtG5VyHJ/X54JHeZUczj7CnvD12yDu4I6e6ZFHAAJ+ZgAAABAAAAABAAAAAQAAABEAAAABAAAAAQAAABAAAAABAAAAAgAAAA8AAAAHRWQyNTUxOQAAAAANAAAAIGsGKxYxF08hS1ap4iEHm23VophJPlpB5uFAHcIbUBDSAAAAEAAAAAEAAAACAAAADwAAAAdFZDI1NTE5AAAAAA0AAABATCGe41Xuovv1w5BXwfdupiYz86Ak2jdg432p8TNRTnHfShfVeiqxIKJr2mTHaCzxxoyNeA2h/4u/vxEOJUycBg==' - -const credentials = xdr.SorobanCredentials.fromXDR(cred_xdr, 'base64') - -xdr.SorobanCredentialsType.sorobanCredentialsAddress - -console.log( - credentials.switch() === - xdr.SorobanCredentialsType.sorobanCredentialsAddress() -); - -const authEntryAddress = Address.fromScAddress( - credentials.address().address(), -).toString(); - -console.log(authEntryAddress); \ No newline at end of file diff --git a/bun_tests/5.index.ts b/bun_tests/5.index.ts deleted file mode 100644 index 9b9b320..0000000 --- a/bun_tests/5.index.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Build an assembled transaction manually - -import { PasskeyClient } from 'passkey-kit' -import { AssembledTransaction } from '@stellar/stellar-sdk/contract'; - -const tx_xdr = 'AAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACZ30AAAAAAAAAAQAAAAEAAAAAAAAAAAAAAABm+xLbAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAIdHJhbnNmZXIAAAADAAAAEgAAAAGeKf+NLk3Jb5f2jCWLsZdKH31uJ94AOPposffyxd+2qAAAABIAAAABh/ZytVEXZeou5dLsuMEZdkkVCjaICnCTq0CmRionsIgAAAAKAAAAAAAAAAAAAAAAAJiWgAAAAAEAAAABAAAAAZ4p/40uTclvl/aMJYuxl0offW4n3gA4+mix9/LF37aoSOpiY5Ioi98AA0z8AAAAEAAAAAEAAAABAAAAEQAAAAEAAAABAAAAEAAAAAEAAAACAAAADwAAAAlTZWNwMjU2cjEAAAAAAAANAAAAFKrB2chB1uk2vswAZo5AakLnkqG/AAAAEAAAAAEAAAACAAAADwAAAAlTZWNwMjU2cjEAAAAAAAARAAAAAQAAAAMAAAAPAAAAEmF1dGhlbnRpY2F0b3JfZGF0YQAAAAAADQAAACXKupuc4WurWb3NCdWC5nW7akt6c3YA07IZIg7j6tAwSR0AAAAAAAAAAAAADwAAABBjbGllbnRfZGF0YV9qc29uAAAADQAAAJh7InR5cGUiOiJ3ZWJhdXRobi5nZXQiLCJjaGFsbGVuZ2UiOiJ5Zi1wZWlsRGlEN002cmhMZWZlMU8zQV80ZlE5STZOVGVIT0YwZ1ljVk00Iiwib3JpZ2luIjoiaHR0cHM6Ly9uZXh0LnBhc3NrZXkta2l0LWRlbW8ucGFnZXMuZGV2IiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQAAAA8AAAAJc2lnbmF0dXJlAAAAAAAADQAAAEAtCj4OlKr5N+YX6ny5jYu5XXmGEq9adAQr/GSm9nPOqhDoqWDRL0yD466GjttJhp235xorK0sLB5xgWeLON2VMAAAAAAAAAAHXkotywnA8z+r365/0701QSlWouXn8m0UOoshCtNHOYQAAAAh0cmFuc2ZlcgAAAAMAAAASAAAAAZ4p/40uTclvl/aMJYuxl0offW4n3gA4+mix9/LF37aoAAAAEgAAAAGH9nK1URdl6i7l0uy4wRl2SRUKNogKcJOrQKZGKiewiAAAAAoAAAAAAAAAAAAAAAAAmJaAAAAAAAAAAAEAAAAAAAAAAQAAAAYAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAUAAAAAQAAAAMAAAAGAAAAAZ4p/40uTclvl/aMJYuxl0offW4n3gA4+mix9/LF37aoAAAAFUjqYmOSKIvfAAAAAAAAAAYAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAQAAAAAQAAAAIAAAAPAAAAB0JhbGFuY2UAAAAAEgAAAAGH9nK1URdl6i7l0uy4wRl2SRUKNogKcJOrQKZGKiewiAAAAAEAAAAGAAAAAdeSi3LCcDzP6vfrn/TvTVBKVai5efybRQ6iyEK00c5hAAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAABnin/jS5NyW+X9owli7GXSh99bifeADj6aLH38sXftqgAAAABAAWL7QAAArgAAAIIAAAAAAACZxkAAAAA' - -const contract = new PasskeyClient({ - contractId: '', - rpcUrl: '', - networkPassphrase: '', -}) - -const at = AssembledTransaction.fromXDR(contract.options, tx_xdr, contract.spec); - -console.log(at.built); diff --git a/bun_tests/6.index.ts b/bun_tests/6.index.ts deleted file mode 100644 index 5dbca56..0000000 --- a/bun_tests/6.index.ts +++ /dev/null @@ -1,3 +0,0 @@ -console.log( - Buffer.from([0, 0, 0, 15, 0, 0, 0, 5, 115, 119, 95, 118, 49, 0, 0, 0]).toString('hex') -); \ No newline at end of file diff --git a/bun_tests/7.index.ts b/bun_tests/7.index.ts deleted file mode 100644 index efcaa0c..0000000 --- a/bun_tests/7.index.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Test an XDR that deploys a contract - -import { Networks, Operation, TransactionBuilder, xdr } from '@stellar/stellar-sdk'; -import { AssembledTransaction, type AssembledTransactionOptions, type Tx } from '@stellar/stellar-sdk/contract'; -import { Client as PasskeyClient } from 'passkey-kit-sdk'; - -let xdr_base64 = 'AAAAAgAAAAC4YTsgfBnWL+VK8itDD/gCnqtirv5cWHO3MXxqONNVmgAAAGQAESPAAAAAAQAAAAEAAAAAAAAAAAAAAABnrkStAAAAAAAAAAEAAAAAAAAAGAAAAAMAAAAAAAAAAAAAAAC4YTsgfBnWL+VK8itDD/gCnqtirv5cWHO3MXxqONNVmvsvbA+ypiNL9AWBaQhdhXtpba5tIv74SARYfnqYLTICAAAAAMH9pZirYl7himeZ8T6ddEuJwyaE/xqaJTNnneY4XZSXAAAAAwAAABIAAAABg53yKxwbQoljlymK88aXMGcZomvOAI8tWIoxYZ7dk3UAAAASAAAAAdeSi3LCcDzP6vfrn/TvTVBKVai5efybRQ6iyEK00c5hAAAAAwAAAAYAAAAAAAAAAAAAAAA='; - -let wallet = new PasskeyClient({ - contractId: 'CC6XIO7LP5GZL6WEAEM2FH2MKTNZ7CL2JF4TKYYGLALLLTWKF5TNJD7U', - networkPassphrase: Networks.TESTNET, - rpcUrl: 'https://soroban-testnet.stellar.org', -}) - -const built = TransactionBuilder.fromXDR(xdr_base64, Networks.TESTNET); -const operation = built.operations[0] as Operation.InvokeHostFunction; -const op = Operation.invokeHostFunction({ - func: operation.func, -}) - -const tx = await AssembledTransaction.buildWithOp(op, { - ...wallet.options, - simulate: false -} as unknown as AssembledTransactionOptions); // buildWithOp(operation, wallet.options); - -console.log(tx); \ No newline at end of file diff --git a/bun_tests/8.index.ts b/bun_tests/8.index.ts deleted file mode 100644 index d9ecb25..0000000 --- a/bun_tests/8.index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Keypair, hash } from "@stellar/stellar-sdk"; - -const keypair = Keypair.fromRawEd25519Seed(hash(Buffer.from('kalepail'))) - -console.log(keypair.secret()); -console.log(keypair.publicKey()); diff --git a/bun_tests/9.index.ts b/bun_tests/9.index.ts deleted file mode 100644 index f682575..0000000 --- a/bun_tests/9.index.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { strict as assert } from "node:assert"; -import { - Address, - Keypair, - Networks, - buildAuthorizationEntryPreimage, - buildWithDelegatesEntry, - hash, - xdr, -} from "@stellar/stellar-sdk"; -import { - assertSignatureExpirationLedger, - buildSignaturePayload, - getAddressCredentials, - usesAddressBoundPayload, -} from "../src/kit"; - -function makeAccount(seedByte: number): string { - return Keypair.fromRawEd25519Seed(Buffer.alloc(32, seedByte)).publicKey(); -} - -function makeInvocation(): xdr.SorobanAuthorizedInvocation { - return new xdr.SorobanAuthorizedInvocation({ - function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( - new xdr.InvokeContractArgs({ - contractAddress: xdr.ScAddress.scAddressTypeContract(hash(Buffer.from("contract"))), - functionName: "do_it", - args: [], - }) - ), - subInvocations: [], - }); -} - -function makeAddressCredentials(address: string, expiration = 1): xdr.SorobanAddressCredentials { - return new xdr.SorobanAddressCredentials({ - address: Address.fromString(address).toScAddress(), - nonce: xdr.Int64.fromString("7"), - signatureExpirationLedger: expiration, - signature: xdr.ScVal.scvVoid(), - }); -} - -function makeAuthEntry(address: string): xdr.SorobanAuthorizationEntry { - return new xdr.SorobanAuthorizationEntry({ - credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( - makeAddressCredentials(address) - ), - rootInvocation: makeInvocation(), - }); -} - -const networkPassphrase = Networks.TESTNET; - -{ - const account = makeAccount(1); - const entry = makeAuthEntry(account); - const credentials = getAddressCredentials(entry.credentials()); - - assert.equal(entry.credentials().switch().name, "sorobanCredentialsAddress"); - assert.equal(Address.fromScAddress(credentials.address()).toString(), account); - assert.equal(usesAddressBoundPayload(entry.credentials()), false); -} - -{ - const entry = makeAuthEntry(makeAccount(2)); - const payload = buildSignaturePayload(networkPassphrase, entry, 123); - const credentials = getAddressCredentials(entry.credentials()); - const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR()); - const expectedPreimage = buildAuthorizationEntryPreimage( - expectedEntry, - 123, - networkPassphrase - ); - - assert.deepEqual(payload, hash(expectedPreimage.toXDR())); - assert.equal(credentials.signatureExpirationLedger(), 123); -} - -{ - const baseEntry = makeAuthEntry(makeAccount(3)); - const credentials = getAddressCredentials(baseEntry.credentials()); - const credentialFactory = xdr.SorobanCredentials as unknown as { - sorobanCredentialsAddressV2: ( - credentials: xdr.SorobanAddressCredentials - ) => xdr.SorobanCredentials; - }; - const addressV2Entry = new xdr.SorobanAuthorizationEntry({ - credentials: credentialFactory.sorobanCredentialsAddressV2(credentials), - rootInvocation: baseEntry.rootInvocation(), - }); - const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR(addressV2Entry.toXDR()); - const expectedPreimage = buildAuthorizationEntryPreimage( - expectedEntry, - 123, - networkPassphrase - ); - - assert.equal(addressV2Entry.credentials().switch().name, "sorobanCredentialsAddressV2"); - assert.equal(usesAddressBoundPayload(addressV2Entry.credentials()), true); - assert.deepEqual( - buildSignaturePayload(networkPassphrase, addressV2Entry, 123), - hash(expectedPreimage.toXDR()) - ); - assert.equal(credentials.signatureExpirationLedger(), 123); -} - -{ - const delegatedEntry = buildWithDelegatesEntry({ - entry: makeAuthEntry(makeAccount(4)), - validUntilLedgerSeq: 123, - delegates: [{ address: makeAccount(5) }], - }); - const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR(delegatedEntry.toXDR()); - const expectedPreimage = buildAuthorizationEntryPreimage( - expectedEntry, - 123, - networkPassphrase - ); - - assert.equal( - delegatedEntry.credentials().switch().name, - "sorobanCredentialsAddressWithDelegates" - ); - assert.equal(usesAddressBoundPayload(delegatedEntry.credentials()), true); - assert.deepEqual( - buildSignaturePayload(networkPassphrase, delegatedEntry, 123), - hash(expectedPreimage.toXDR()) - ); - assert.equal(getAddressCredentials(delegatedEntry.credentials()).signatureExpirationLedger(), 123); -} - -{ - const fakeAddressV2Credentials = { - switch: () => ({ name: "sorobanCredentialsAddressV2" }), - } as unknown as xdr.SorobanCredentials; - - assert.throws( - () => getAddressCredentials(fakeAddressV2Credentials), - /ADDRESS_V2 credentials require an SDK with Protocol 27 credential support/ - ); -} - -{ - const fakeDelegatedCredentials = { - switch: () => ({ name: "sorobanCredentialsAddressWithDelegates" }), - } as unknown as xdr.SorobanCredentials; - - assert.equal(usesAddressBoundPayload(fakeDelegatedCredentials), true); - assert.throws( - () => getAddressCredentials(fakeDelegatedCredentials), - /ADDRESS_WITH_DELEGATES credentials require an SDK with Protocol 27 credential support/ - ); -} - -{ - assert.doesNotThrow(() => assertSignatureExpirationLedger(0)); - assert.doesNotThrow(() => assertSignatureExpirationLedger(0xffffffff)); - assert.throws(() => assertSignatureExpirationLedger(-1), /uint32 integer/); - assert.throws(() => assertSignatureExpirationLedger(0x100000000), /uint32 integer/); - assert.throws(() => assertSignatureExpirationLedger(1.5), /uint32 integer/); - assert.throws(() => assertSignatureExpirationLedger(Number.NaN), /uint32 integer/); -} - -{ - const entry = makeAuthEntry(makeAccount(4)); - const credentials = getAddressCredentials(entry.credentials()); - - assert.throws( - () => buildSignaturePayload(networkPassphrase, entry, 1.5), - /Soroban signature expiration ledger must be a uint32 integer/ - ); - assert.equal(credentials.signatureExpirationLedger(), 1); -} - -console.log("Protocol 27 auth helper tests passed"); diff --git a/bun_tests/README.md b/bun_tests/README.md deleted file mode 100644 index b22b4b0..0000000 --- a/bun_tests/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# tests - -To install dependencies: - -```bash -bun install -``` - -To run: - -```bash -bun run index.ts -``` - -This project was created using `bun init` in bun v1.1.8. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun_tests/bun.lockb b/bun_tests/bun.lockb deleted file mode 100755 index d8e2ac6125b612dff75b9bcda26747f4790504dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3439 zcmdT`dr(wm6u*lL;-a90rcUZ2h9&OZ2P_X+!AH(mJDOlZzN!`OE*F+vxc9=c;OL@g z)MzNCG0vpf`XK2=laDMdHMB$>GR7J$fojA|$tf|NZ0(%g`^jZWUDQAH?3_K{_ucdR z-Sd6t+bY8I{@2<(mXmu|^R(t#}iAg(?^1y+-ua7UoN{~ zIaV3|+sM`GIV+=gj8E!gHyk>jw*FIhV~ulT?g@6|_-~$0P6cnvL zLhzFT9|w3W48&p`_#*_r6z~S|{)q484_Nd;U*eAzs3-^OLpK=qc~CcO)?M;gH55hS|pt z2(`^2{ocsk>ozwyRlAB~`h{+7bxqX`nzUs2i!Qi~3i#7qo0wCdSC>GjR239m9arWFzEe-Jhwh;QN-r_>2K zk!hL++3JIQN>vRT*DGaRprde3%z6p0w9e(xGgc(GZxSXcwx<=Yd3!{LDZsr_&nH*X+)1-GWp0VAtUo;Wt>Eyb z%NLK|S0*3aT7Ox>OX?WULhc!Q<}0H+XPD;qcaON=@9dc0c%th30ApJD>E@to*~^!0 zNSYa?Ydbc4n(V2^ep-{fJk)I-AJlqw^wIFT-t@?bLg0OngyfjmyNRvGTk0|3ZlZel zF&~OA%r8Z&Fz{R!&DwauWVOX}II6C8+N z@BZMM4Hg1$kuE4erlsMRKvA{g719R<$QC8idRnWYNT)?QCb**0&{`dxNO~{QL%|h2 zt&6AQ{s%J#_)%)beV-F;_k8nAelf$*Q%VGevze?j1&*~@Qk2@Rqe<+3G?c(+d3nG1 zJ7Np>`l<+QSaB`vjN8yLtZI0%3x8sD`fC%G&bA6_|KFk8!H7M2${ zCv7Xp&*f=5XHj?I)hc9BVJhNj!BUD7FZpfIH+@8~*WXK_O+XHj-NB0kn?0Mk4V}ab z2+>*POR_c&*@|r0Y!-XRprmd", "license": "MIT", "type": "module", - "main": "src/index.ts", - "types": "types/index.d.ts", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./storage": { + "import": "./dist/storage/index.js", + "types": "./dist/storage/index.d.ts" + }, + "./server": { + "import": "./dist/server.js", + "types": "./dist/server.d.ts" + } + }, + "files": [ + "dist", + "README.md", + "LICENCE" + ], "scripts": { + "sync-version": "node scripts/sync-version.js", + "prebuild": "node scripts/sync-version.js", + "build:bindings": "pnpm --filter sac-sdk run build && pnpm --filter passkey-kit-sdk run build", + "build": "pnpm run build:bindings && tsc && node scripts/verify-esm.mjs", + "verify:esm": "node scripts/verify-esm.mjs", + "test": "vitest", + "clean": "rm -rf dist packages/passkey-kit-sdk/dist packages/sac-sdk/dist", "build:demo": "cd demo && pnpm --ignore-workspace install && pnpm run build", "deploy:demo": "npx wrangler pages deploy", "deploy:demo:prod": "npx wrangler pages deploy --env production", - "prepublishOnly": "pnpm run build", - "test:p27-auth": "bun bun_tests/9.index.ts", - "build:sac": "pnpm --filter=sac-sdk run build", - "build:pks": "pnpm --filter=passkey-kit-sdk run build", - "build": "pnpm run build:sac && pnpm run build:pks && tsc", - "ext:cp": "./clone-js-sdk.sh" + "prepublishOnly": "pnpm run build" }, "dependencies": { - "@openzeppelin/relayer-plugin-channels": "^0.5.0", - "@simplewebauthn/browser": "^13.2.0", - "@stellar/stellar-sdk": "16.0.0-rc.1", + "@openzeppelin/relayer-plugin-channels": "^0.20.0", + "@simplewebauthn/browser": "^13.3.0", + "@stellar/stellar-sdk": "16.0.1", "base64url": "^3.0.1", "buffer": "^6.0.3", "passkey-kit-sdk": "workspace:*", @@ -29,18 +52,23 @@ }, "devDependencies": { "@types/node": "^24.6.0", - "typescript": "^5.9.2" + "fake-indexeddb": "^6.2.5", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "@stellar/stellar-sdk": ">=16.0.0" }, "pnpm": { "overrides": { "@stellar/stellar-sdk": "$@stellar/stellar-sdk" - } + }, + "onlyBuiltDependencies": [ + "esbuild" + ] }, - "workspaces": [ - "packages/*" - ], "engines": { - "node": ">=22.18.0" + "node": ">=22.0.0" }, "repository": { "type": "git", diff --git a/packages/passkey-kit-sdk/package.json b/packages/passkey-kit-sdk/package.json index 7278770..f9d2000 100644 --- a/packages/passkey-kit-sdk/package.json +++ b/packages/passkey-kit-sdk/package.json @@ -2,17 +2,32 @@ "version": "0.7.3", "name": "passkey-kit-sdk", "type": "module", - "main": "src/index.ts", - "types": "types/index.d.ts", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "README.md" + ], "scripts": { "prepublishOnly": "pnpm run build", "build": "tsc" }, "dependencies": { - "@stellar/stellar-sdk": "16.0.0-rc.1", "buffer": "^6.0.3" }, + "peerDependencies": { + "@stellar/stellar-sdk": ">=16.0.0" + }, "devDependencies": { + "@stellar/stellar-sdk": "16.0.1", "typescript": "^5.9.2" }, "engines": { diff --git a/packages/passkey-kit-sdk/tsconfig.json b/packages/passkey-kit-sdk/tsconfig.json index 9d42a28..2ab5e5f 100644 --- a/packages/passkey-kit-sdk/tsconfig.json +++ b/packages/passkey-kit-sdk/tsconfig.json @@ -1,98 +1,16 @@ { "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - /* Language and Environment */ - "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - /* Modules */ - "module": "NodeNext", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - /* Emit */ - "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./types", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - // "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - // "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - /* Type Checking */ - // "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "esModuleInterop": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true }, - "include": [ - "src/*" - ] -} \ No newline at end of file + "include": ["src/**/*"] +} diff --git a/packages/sac-sdk/package.json b/packages/sac-sdk/package.json index 17d46d3..3fd45df 100644 --- a/packages/sac-sdk/package.json +++ b/packages/sac-sdk/package.json @@ -2,17 +2,32 @@ "version": "0.4.3", "name": "sac-sdk", "type": "module", - "main": "src/index.ts", - "types": "types/index.d.ts", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "README.md" + ], "scripts": { "prepublishOnly": "pnpm run build", "build": "tsc" }, "dependencies": { - "@stellar/stellar-sdk": "16.0.0-rc.1", "buffer": "^6.0.3" }, + "peerDependencies": { + "@stellar/stellar-sdk": ">=16.0.0" + }, "devDependencies": { + "@stellar/stellar-sdk": "16.0.1", "typescript": "^5.9.2" }, "engines": { diff --git a/packages/sac-sdk/tsconfig.json b/packages/sac-sdk/tsconfig.json index 9d42a28..2ab5e5f 100644 --- a/packages/sac-sdk/tsconfig.json +++ b/packages/sac-sdk/tsconfig.json @@ -1,98 +1,16 @@ { "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - /* Language and Environment */ - "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - /* Modules */ - "module": "NodeNext", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - /* Emit */ - "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./types", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - // "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - // "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - /* Type Checking */ - // "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "esModuleInterop": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true }, - "include": [ - "src/*" - ] -} \ No newline at end of file + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a16532..66c9da9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,21 +5,21 @@ settings: excludeLinksFromLockfile: false overrides: - '@stellar/stellar-sdk': 16.0.0-rc.1 + '@stellar/stellar-sdk': 16.0.1 importers: .: dependencies: '@openzeppelin/relayer-plugin-channels': - specifier: ^0.5.0 - version: 0.5.0 + specifier: ^0.20.0 + version: 0.20.0 '@simplewebauthn/browser': - specifier: ^13.2.0 - version: 13.2.0 + specifier: ^13.3.0 + version: 13.3.0 '@stellar/stellar-sdk': - specifier: 16.0.0-rc.1 - version: 16.0.0-rc.1 + specifier: 16.0.1 + version: 16.0.1 base64url: specifier: ^3.0.1 version: 3.0.1 @@ -36,32 +36,38 @@ importers: '@types/node': specifier: ^24.6.0 version: 24.6.0 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 typescript: specifier: ^5.9.2 version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.6.0) packages/passkey-kit-sdk: dependencies: - '@stellar/stellar-sdk': - specifier: 16.0.0-rc.1 - version: 16.0.0-rc.1 buffer: specifier: ^6.0.3 version: 6.0.3 devDependencies: + '@stellar/stellar-sdk': + specifier: 16.0.1 + version: 16.0.1 typescript: specifier: ^5.9.2 version: 5.9.2 packages/sac-sdk: dependencies: - '@stellar/stellar-sdk': - specifier: 16.0.0-rc.1 - version: 16.0.0-rc.1 buffer: specifier: ^6.0.3 version: 6.0.3 devDependencies: + '@stellar/stellar-sdk': + specifier: 16.0.1 + version: 16.0.1 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -74,6 +80,165 @@ packages: '@actions/io@1.1.3': resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@noble/ed25519@3.1.0': resolution: {integrity: sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==} @@ -81,39 +246,216 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} - '@openzeppelin/relayer-plugin-channels@0.5.0': - resolution: {integrity: sha512-mn8TklTFwwqKVVOjYQtJGGX/8IKJwg4hbwfAsz7kvb5L5EhjJzwCjgfT84TL6ZVmHb7DoQ6h2lPgNussDiHy5g==} + '@openzeppelin/relayer-plugin-channels@0.20.0': + resolution: {integrity: sha512-buZ0Xcir1mPcevMBU3vzbq4v77YUsikWX+E1k31YxdE6p97GEn40RnxNzow/eLNVnS3WNwYZ1SuEZgsQE7KHAg==} engines: {node: '>=22.18.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm} - '@openzeppelin/relayer-sdk@1.8.0': - resolution: {integrity: sha512-2nMn9Sg0j52kp/GdhavvL8zgwRJqDqXq4yM1uYewfYndns6WGUS3zxcDICgHenMVURo8YWdJG7E7Ockck3SNFg==} + '@openzeppelin/relayer-sdk@1.10.0': + resolution: {integrity: sha512-C7v4C63VnFR4keISicpcDf4Ix646sa1OdokzddFeSPEGlJIY2b/XJ9sIWiXOBVJVGzbB+RdX3/fbkygAPhcVNw==} engines: {node: '>=22.14.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm} - '@simplewebauthn/browser@13.2.0': - resolution: {integrity: sha512-N3fuA1AAnTo5gCStYoIoiasPccC+xPLx2YU88Dv0GeAmPQTWHETlZQq5xZ0DgUq1H9loXMWQH5qqUjcI7BHJ1A==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} '@stellar/js-xdr@4.0.0': resolution: {integrity: sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==} engines: {node: '>=20.0.0', pnpm: '>=9.0.0'} - '@stellar/stellar-sdk@16.0.0-rc.1': - resolution: {integrity: sha512-mkOQUsFgepFIxRFJLgjkLAkYQDBdNKgLpMM5yIWQcCUoXavb0gO1Dlw/hPqVg2KBKUKaXs2ljVLuB4x6bSWR0g==} + '@stellar/stellar-sdk@16.0.1': + resolution: {integrity: sha512-bxKohaiyKVqoudRhbOOHeHhHIaeYV5Zab4rCjxhP4Ty1h1ozTLBOv8lWFnZz9ilBzXG8Bb7usQI3rlEcfvUynA==} engines: {node: '>=22.0.0'} hasBin: true + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@24.6.0': resolution: {integrity: sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==} + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.13.2: - resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} - axios@1.16.1: resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} @@ -134,10 +476,22 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -155,6 +509,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -171,6 +529,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -179,6 +540,14 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -187,18 +556,26 @@ packages: resolution: {integrity: sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==} engines: {node: '>=20.0.0'} - feaxios@0.0.23: - resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} - engines: {node: '>=4.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: - debug: '*' + picomatch: ^3 || ^4 peerDependenciesMeta: - debug: + picomatch: optional: true + feaxios@0.0.23: + resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} + follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -208,14 +585,15 @@ packages: debug: optional: true - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -254,6 +632,15 @@ packages: resolution: {integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==} engines: {node: '>=12'} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -269,17 +656,80 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.17: + resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} + engines: {node: ^10 || ^12 || >=14} proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -292,6 +742,84 @@ packages: undici-types@7.13.0: resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + snapshots: '@actions/exec@1.1.1': @@ -300,31 +828,187 @@ snapshots: '@actions/io@1.1.3': {} + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + '@noble/ed25519@3.1.0': {} '@noble/hashes@2.2.0': {} - '@openzeppelin/relayer-plugin-channels@0.5.0': + '@openzeppelin/relayer-plugin-channels@0.20.0': dependencies: '@actions/exec': 1.1.1 - '@openzeppelin/relayer-sdk': 1.8.0 - '@stellar/stellar-sdk': 16.0.0-rc.1 - axios: 1.13.2 + '@openzeppelin/relayer-sdk': 1.10.0 + '@stellar/stellar-sdk': 16.0.1 + axios: 1.16.1 transitivePeerDependencies: - debug - supports-color - '@openzeppelin/relayer-sdk@1.8.0': + '@openzeppelin/relayer-sdk@1.10.0': dependencies: - axios: 1.13.2 + axios: 1.16.1 transitivePeerDependencies: - debug + - supports-color + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true - '@simplewebauthn/browser@13.2.0': {} + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@simplewebauthn/browser@13.3.0': {} '@stellar/js-xdr@4.0.0': {} - '@stellar/stellar-sdk@16.0.0-rc.1': + '@stellar/stellar-sdk@16.0.1': dependencies: '@noble/ed25519': 3.1.0 '@noble/hashes': 2.2.0 @@ -342,25 +1026,70 @@ snapshots: - debug - supports-color + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@24.6.0': dependencies: undici-types: 7.13.0 + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.6.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@24.6.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + agent-base@6.0.2: dependencies: debug: 4.4.3 transitivePeerDependencies: - supports-color - asynckit@0.4.0: {} + assertion-error@2.0.1: {} - axios@1.13.2: - dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug + asynckit@0.4.0: {} axios@1.16.1: dependencies: @@ -385,11 +1114,23 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -400,6 +1141,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + delayed-stream@1.0.0: {} dunder-proto@1.0.1: @@ -412,6 +1155,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -423,28 +1168,59 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + eventsource-parser@3.1.0: {} eventsource@4.1.0: dependencies: eventsource-parser: 3.1.0 + expect-type@1.4.0: {} + + fake-indexeddb@6.2.5: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + feaxios@0.0.23: dependencies: is-retry-allowed: 3.0.0 - follow-redirects@1.15.9: {} - follow-redirects@1.16.0: {} - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -453,6 +1229,9 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-intrinsic@1.3.0: @@ -496,6 +1275,14 @@ snapshots: is-retry-allowed@3.0.0: {} + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} mime-db@1.52.0: {} @@ -506,14 +1293,165 @@ snapshots: ms@2.1.3: {} - proxy-from-env@1.1.0: {} + nanoid@3.3.15: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.17: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 proxy-from-env@2.1.0: {} + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + smol-toml@1.6.1: {} + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + typescript@5.9.2: {} uint8array-extras@1.5.0: {} undici-types@7.13.0: {} + + vite-node@3.2.4(@types/node@24.6.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@24.6.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@24.6.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.17 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.6.0 + fsevents: 2.3.3 + + vitest@3.2.7(@types/node@24.6.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.6.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@24.6.0) + vite-node: 3.2.4(@types/node@24.6.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.6.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/scripts/sync-version.js b/scripts/sync-version.js new file mode 100644 index 0000000..0b3f6d9 --- /dev/null +++ b/scripts/sync-version.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +/** + * Syncs the version + name from package.json into src/version.ts. + * + * Runs automatically before `build` (prebuild), so the compiled package always + * reports the version it was published as. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, ".."); + +const pkg = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf-8")); + +const contents = `/** + * Package version — auto-generated from package.json. + * @packageDocumentation + */ + +// This file is auto-generated. Do not edit manually. +// Run \`pnpm run sync-version\` (or \`pnpm build\`) to update. + +export const VERSION = "${pkg.version}"; +export const NAME = "${pkg.name}"; +`; + +writeFileSync(join(rootDir, "src/version.ts"), contents); + +console.log(`✓ Synced version to ${pkg.version}`); diff --git a/scripts/verify-esm.mjs b/scripts/verify-esm.mjs new file mode 100644 index 0000000..911a4fd --- /dev/null +++ b/scripts/verify-esm.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/** + * Node native-ESM import smoke test. + * + * Regression gate for a real class of packaging bug: `tsc` under + * `moduleResolution: "bundler"` emits extensionless relative imports + * (`from "./kit"`) that Node's native ESM loader rejects + * (`ERR_MODULE_NOT_FOUND` / `ERR_UNSUPPORTED_DIR_IMPORT`). Such a package imports + * fine under a bundler (Vite/webpack) but crashes for any plain Node consumer — + * a gap a Vite-only demo e2e never exercises. + * + * This imports the built package the way a Node consumer would — by name, via + * package self-reference through `exports` — and asserts key exports resolve. It + * runs as part of `pnpm build`, so a regression fails the build (and release). + */ +const failures = []; + +async function check(specifier, expectedExports) { + try { + const mod = await import(specifier); + for (const name of expectedExports) { + if (!(name in mod)) { + failures.push(`${specifier}: missing export "${name}"`); + } + } + console.log(` ok ${specifier} (${expectedExports.join(", ")})`); + } catch (err) { + const detail = `${err.code ?? ""} ${String(err.message).split("\n")[0]}`; + failures.push(`${specifier}: ${detail}`); + console.log(` FAIL ${specifier}: ${detail}`); + } +} + +console.log("Node ESM import smoke test:"); +await check("passkey-kit", [ + "PasskeyKit", + "PasskeyServer", + "PasskeyKitError", + "PasskeyEventEmitter", + "deriveContractAddress", +]); +await check("passkey-kit/storage", [ + "MemoryStorage", + "LocalStorageAdapter", + "IndexedDBStorage", +]); +await check("passkey-kit/server", ["PasskeyServer"]); + +if (failures.length) { + console.error("\nESM import smoke test FAILED:"); + for (const f of failures) console.error(" - " + f); + console.error( + "\nThe built package cannot be imported by a Node ESM consumer. This usually\n" + + "means a relative import is missing its explicit .js extension (NodeNext)." + ); + process.exit(1); +} +console.log("\nESM import smoke test passed."); diff --git a/src/base64url.ts b/src/base64url.ts new file mode 100644 index 0000000..c1c108a --- /dev/null +++ b/src/base64url.ts @@ -0,0 +1,19 @@ +/** + * `base64url` interop shim. + * + * The `base64url` package ships ESM-style type declarations + * (`export default base64url`) over a CommonJS runtime. Under NodeNext module + * resolution the default import is typed as the module namespace object rather + * than the callable `Base64Url`, so both calling it (`base64url(...)`) and + * member access (`base64url.toBuffer(...)`) fail to type-check. At runtime + * `esModuleInterop` already yields the callable, so this is a type-only + * correction, centralized here rather than repeated at every call site. + * + * Every module in the kit imports base64url from here, never directly from the + * package, so the interop fix lives in exactly one place. + */ +import base64urlDefault, { type Base64Url } from "base64url"; + +const base64url = base64urlDefault as unknown as Base64Url; + +export default base64url; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..c38a7dc --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,103 @@ +/** + * Constants used throughout the Passkey Kit SDK. + * + * Cryptographic sizes mirror the smart-wallet contract; the deployer seed is + * load-bearing for deterministic contract-id derivation (see below). + * + * @packageDocumentation + */ + +// ============================================================================ +// Cryptographic constants (mirror the smart-wallet contract) +// ============================================================================ + +/** Size of an uncompressed secp256r1 (P-256) public key, in bytes. */ +export const SECP256R1_PUBLIC_KEY_SIZE = 65; + +/** First byte of an uncompressed secp256r1 public key (0x04). */ +export const UNCOMPRESSED_PUBKEY_PREFIX = 0x04; + +/** Size of an Ed25519 public key, in bytes. */ +export const ED25519_PUBLIC_KEY_SIZE = 32; + +/** Size of an Ed25519 signature, in bytes. */ +export const ED25519_SIGNATURE_SIZE = 64; + +/** + * Order (n) of the secp256r1 (P-256) curve, used to enforce low-S signatures. + * @see https://github.com/stellar/stellar-protocol/discussions/1435 + */ +export const SECP256R1_CURVE_ORDER = BigInt( + "0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551" +); + +// ============================================================================ +// WebAuthn configuration +// ============================================================================ + +/** Default timeout for WebAuthn ceremonies, in milliseconds. */ +export const WEBAUTHN_TIMEOUT_MS = 60_000; + +/** Length, in bytes, of a randomly generated WebAuthn challenge. */ +export const WEBAUTHN_CHALLENGE_SIZE = 32; + +// ============================================================================ +// Deployer +// ============================================================================ + +/** + * Seed for the default (deterministic) deployer keypair. + * + * The deployer keypair is derived as + * `Keypair.fromRawEd25519Seed(sha256(utf8(DEFAULT_DEPLOYER_SEED)))`. Deriving it + * from a fixed, well-known string makes smart-wallet contract IDs reproducible + * across clients from a passkey credential ID alone (`salt = sha256(keyId)`, + * `deployer = this keypair`), which is exactly what the indexer reverse-lookup + * (keyId → contract) depends on. + * + * This value MUST remain `"kalepail"`: every wallet ever deployed by passkey-kit + * used this deployer, and the derivation is load-bearing for discovery. The + * deployer only pays fees and salts the deploy — it never controls the wallet — + * but it IS a shared, publicly-derivable keypair. Provide your own + * `deploySource` secret for a dedicated fee payer (note: a different deployer + * changes the derived contract addresses and breaks keyId → contract discovery). + */ +export const DEFAULT_DEPLOYER_SEED = "kalepail"; + +// ============================================================================ +// Transaction timeouts +// ============================================================================ + +/** + * Default transaction timeout, in seconds. + * + * The OpenZeppelin Relayer requires a time-bound of <= 30s on submitted + * transactions, so the kit defaults to that. + */ +export const DEFAULT_TIMEOUT_SECONDS = 30; + +// ============================================================================ +// Storage configuration +// ============================================================================ + +/** Default IndexedDB database name for passkey credential storage. */ +export const DB_NAME = "passkey-kit"; + +/** Current IndexedDB schema version. */ +export const DB_VERSION = 1; + +/** IndexedDB object-store name for passkey credentials. */ +export const IDB_STORE_CREDENTIALS = "credentials"; + +/** IndexedDB index name for contract-id lookups. */ +export const IDB_INDEX_CONTRACT_ID = "contractId"; + +/** localStorage key under which passkey credentials are persisted. */ +export const LOCALSTORAGE_CREDENTIALS_KEY = "passkey-kit:credentials"; + +// ============================================================================ +// Networks +// ============================================================================ + +/** Stellar Friendbot URL for testnet funding. */ +export const FRIENDBOT_URL = "https://friendbot.stellar.org"; diff --git a/src/contract-errors.test.ts b/src/contract-errors.test.ts new file mode 100644 index 0000000..3eed3e1 --- /dev/null +++ b/src/contract-errors.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { Errors as BindingsErrors } from "passkey-kit-sdk"; +import { + CONTRACT_ERROR_REGISTRY, + contractErrorFromCode, + decodeContractError, + failedTransaction, + simulationFailure, + submissionFailure, +} from "./contract-errors.js"; +import { + ContractError, + SimulationError, + SubmissionError, + ValidationError, + PasskeyKitErrorCode, +} from "./errors.js"; + +describe("decodeContractError", () => { + it("decodes a contract error from a diagnostic string", () => { + const error = decodeContractError( + "HostError: Error(Contract, #4) ... SignerExpired" + ); + expect(error).toBeInstanceOf(ContractError); + expect(error?.contractCode).toBe(4); + expect(error?.contractErrorName).toBe("SignerExpired"); + expect(error?.family).toBe("SmartWallet"); + expect(error?.code).toBe(PasskeyKitErrorCode.CONTRACT_ERROR); + }); + + it.each([ + [1, "NotFound"], + [2, "AlreadyExists"], + [3, "MissingContext"], + [7, "SignatureKeyValueMismatch"], + [8, "ClientDataJsonChallengeIncorrect"], + [9, "JsonParseError"], + ])("decodes #%i as %s", (code, name) => { + const error = decodeContractError(`Error(Contract, #${code})`); + expect(error?.contractCode).toBe(code); + expect(error?.contractErrorName).toBe(name); + expect(error?.family).toBe("SmartWallet"); + }); + + it("handles whitespace variations in the marker", () => { + expect(decodeContractError("Error(Contract,#2)")?.contractErrorName).toBe( + "AlreadyExists" + ); + expect(decodeContractError("Error(Contract, #2)")?.contractErrorName).toBe( + "AlreadyExists" + ); + }); + + it("decodes from an Error object's message", () => { + const error = decodeContractError(new Error("Error(Contract, #5)")); + expect(error?.contractErrorName).toBe("FailedSignerLimits"); + }); + + it("returns null when there is no contract marker", () => { + expect(decodeContractError("some unrelated error")).toBeNull(); + expect(decodeContractError("")).toBeNull(); + expect(decodeContractError(null)).toBeNull(); + expect(decodeContractError(undefined)).toBeNull(); + }); + + it("returns null for an unknown contract code", () => { + expect(decodeContractError("Error(Contract, #9999)")).toBeNull(); + }); +}); + +describe("contractErrorFromCode", () => { + it("builds a ContractError for a known code", () => { + const error = contractErrorFromCode(6); + expect(error).toBeInstanceOf(ContractError); + expect(error?.contractErrorName).toBe("FailedPolicySignerLimits"); + }); + + it("returns null for an unknown code", () => { + expect(contractErrorFromCode(1234)).toBeNull(); + }); +}); + +describe("CONTRACT_ERROR_REGISTRY", () => { + it("stays in sync with the generated bindings' Errors map", () => { + // The bindings' Errors map is the source of truth; a regen must not drift + // from this registry (this is the guard the B4 resync relies on). + const bindingsEntries = Object.entries( + BindingsErrors as Record + ); + expect(bindingsEntries.length).toBeGreaterThan(0); + + for (const [codeStr, { message: name }] of bindingsEntries) { + const code = Number(codeStr); + const info = CONTRACT_ERROR_REGISTRY[code]; + expect(info, `registry missing SmartWallet code ${code}`).toBeDefined(); + expect(info.name).toBe(name); + expect(info.family).toBe("SmartWallet"); + } + }); + + it("keeps every entry's key aligned with its code field", () => { + for (const [key, info] of Object.entries(CONTRACT_ERROR_REGISTRY)) { + expect(info.code).toBe(Number(key)); + } + }); +}); + +describe("transaction failure helpers", () => { + it("failedTransaction exposes error.code and omits an empty hash", () => { + const failure = failedTransaction(new ValidationError("bad input")); + expect(failure.success).toBe(false); + expect(failure.error).toBeInstanceOf(ValidationError); + expect(failure.error.code).toBe(PasskeyKitErrorCode.INVALID_INPUT); + expect(failure.hash).toBeUndefined(); + }); + + it("failedTransaction preserves a provided hash", () => { + const failure = failedTransaction(new SubmissionError("boom"), "abc123"); + expect(failure.hash).toBe("abc123"); + }); + + it("simulationFailure decodes a contract error when present", () => { + const failure = simulationFailure("Error(Contract, #4)"); + expect(failure.error).toBeInstanceOf(ContractError); + expect(failure.error.code).toBe(PasskeyKitErrorCode.CONTRACT_ERROR); + }); + + it("simulationFailure falls back to SimulationError", () => { + const failure = simulationFailure("host is unreachable"); + expect(failure.error).toBeInstanceOf(SimulationError); + expect(failure.error.message).toContain("host is unreachable"); + }); + + it("submissionFailure falls back to SubmissionError and keeps the hash", () => { + const failure = submissionFailure("rejected", "tx-hash"); + expect(failure.error).toBeInstanceOf(SubmissionError); + expect(failure.hash).toBe("tx-hash"); + }); +}); diff --git a/src/contract-errors.ts b/src/contract-errors.ts new file mode 100644 index 0000000..2fb5ddb --- /dev/null +++ b/src/contract-errors.ts @@ -0,0 +1,163 @@ +/** + * Contract error decoding. + * + * Maps on-chain smart-wallet failure codes (surfaced in simulation/submission + * diagnostics as `Error(Contract, #N)`) to typed {@link ContractError}s with the + * enum variant name and a human-readable message. + * + * The `SmartWallet` family is the source of truth in the generated bindings' + * `Errors` map; `contract-errors.test.ts` asserts this registry stays in sync + * with it, so a bindings regen surfaces any drift. + * + * NOTE: these are the CURRENT contract's 9 codes (1-9). The reworked contract + * deliberately renumbers and adds codes (spec #602); this registry is resynced + * to the regenerated bindings in B4. + * + * @packageDocumentation + */ + +import { + ContractError, + SimulationError, + SubmissionError, + PasskeyKitError, +} from "./errors.js"; +import type { TransactionFailure } from "./types.js"; + +/** + * Contract families that expose custom error codes. + */ +export type ContractErrorFamily = "SmartWallet"; + +/** + * A single contract error entry: code, enum variant name, family, and a + * human-readable message. + */ +export interface ContractErrorInfo { + code: number; + name: string; + family: ContractErrorFamily; + message: string; +} + +function entry( + code: number, + name: string, + family: ContractErrorFamily, + message: string +): [number, ContractErrorInfo] { + return [code, { code, name, family, message }]; +} + +/** + * Registry of every known contract error code, keyed by numeric code. + */ +export const CONTRACT_ERROR_REGISTRY: Readonly< + Record +> = Object.freeze( + Object.fromEntries([ + entry(1, "NotFound", "SmartWallet", "The specified signer was not found."), + entry(2, "AlreadyExists", "SmartWallet", "The signer already exists on this wallet."), + entry(3, "MissingContext", "SmartWallet", "The signature did not cover a required authorization context."), + entry(4, "SignerExpired", "SmartWallet", "The signer has expired."), + entry(5, "FailedSignerLimits", "SmartWallet", "The signer is not permitted to authorize this context by its limits."), + entry(6, "FailedPolicySignerLimits", "SmartWallet", "A policy signer's limits rejected this context."), + entry(7, "SignatureKeyValueMismatch", "SmartWallet", "A signature entry's key and value did not match."), + entry(8, "ClientDataJsonChallengeIncorrect", "SmartWallet", "The WebAuthn clientDataJSON challenge did not match the signature payload."), + entry(9, "JsonParseError", "SmartWallet", "The WebAuthn clientDataJSON could not be parsed."), + ]) +); + +/** + * Matches the host's rendering of a contract error, e.g. `Error(Contract, #4)`. + */ +const CONTRACT_ERROR_PATTERN = /Error\(Contract,\s*#(\d+)\)/; + +function diagnosticToString(diagnostic: unknown): string { + if (diagnostic == null) return ""; + if (typeof diagnostic === "string") return diagnostic; + if (diagnostic instanceof Error) return diagnostic.message; + try { + return JSON.stringify(diagnostic); + } catch { + return String(diagnostic); + } +} + +/** + * Build a {@link ContractError} for a known contract code, or `null` if the code + * is not in the registry. + */ +export function contractErrorFromCode( + code: number, + context?: Record +): ContractError | null { + const info = CONTRACT_ERROR_REGISTRY[code]; + if (!info) return null; + return new ContractError(info.code, info.name, info.family, info.message, { + context, + }); +} + +/** + * Decode a simulation/submission diagnostic into a typed {@link ContractError}. + * + * Scans the (stringified) diagnostic for an `Error(Contract, #N)` marker and + * looks the code up in {@link CONTRACT_ERROR_REGISTRY}. Returns `null` when no + * contract code is present or the code is unknown, letting callers fall back to + * a generic {@link SimulationError}/{@link SubmissionError}. + */ +export function decodeContractError(diagnostic: unknown): ContractError | null { + const text = diagnosticToString(diagnostic); + const match = text.match(CONTRACT_ERROR_PATTERN); + if (!match) return null; + const code = Number.parseInt(match[1]!, 10); + return contractErrorFromCode(code, { diagnostic: text }); +} + +/** + * Build a {@link TransactionFailure} from an already-typed error. + */ +export function failedTransaction( + error: PasskeyKitError, + hash?: string +): TransactionFailure { + return { + success: false, + error, + ...(hash ? { hash } : {}), + }; +} + +/** + * Build a {@link TransactionFailure} from a simulation diagnostic, decoding a + * contract error when present and otherwise wrapping it in a + * {@link SimulationError}. + */ +export function simulationFailure( + diagnostic: unknown, + hash?: string +): TransactionFailure { + const error = + decodeContractError(diagnostic) ?? + new SimulationError(diagnosticToString(diagnostic) || "Simulation failed"); + return failedTransaction(error, hash); +} + +/** + * Build a {@link TransactionFailure} from a submission diagnostic, decoding a + * contract error when present and otherwise wrapping it in a + * {@link SubmissionError}. + */ +export function submissionFailure( + diagnostic: unknown, + hash?: string +): TransactionFailure { + const error = + decodeContractError(diagnostic) ?? + new SubmissionError( + diagnosticToString(diagnostic) || "Submission failed", + hash + ); + return failedTransaction(error, hash); +} diff --git a/src/errors.test.ts b/src/errors.test.ts new file mode 100644 index 0000000..9d5e340 --- /dev/null +++ b/src/errors.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + PasskeyKitError, + PasskeyKitErrorCode, + ContractError, + WalletNotConnectedError, + WebAuthnError, + ValidationError, + wrapError, +} from "./errors.js"; + +describe("PasskeyKitError", () => { + it("carries code, context, and cause", () => { + const cause = new Error("root cause"); + const err = new PasskeyKitError("boom", PasskeyKitErrorCode.INVALID_CONFIG, { + context: { foo: "bar" }, + cause, + }); + expect(err.code).toBe(PasskeyKitErrorCode.INVALID_CONFIG); + expect(err.context).toEqual({ foo: "bar" }); + expect(err.cause).toBe(cause); + expect(err).toBeInstanceOf(Error); + }); + + it("formats a detailed string with code, context, and cause", () => { + const err = new PasskeyKitError("boom", PasskeyKitErrorCode.INVALID_CONFIG, { + context: { foo: "bar" }, + cause: new Error("underlying"), + }); + const detailed = err.toDetailedString(); + expect(detailed).toContain(`[${PasskeyKitErrorCode.INVALID_CONFIG}]`); + expect(detailed).toContain("boom"); + expect(detailed).toContain("foo"); + expect(detailed).toContain("Caused by: underlying"); + }); +}); + +describe("error subclasses", () => { + it("WalletNotConnectedError references the operation", () => { + const err = new WalletNotConnectedError("add a signer"); + expect(err.code).toBe(PasskeyKitErrorCode.WALLET_NOT_CONNECTED); + expect(err.message).toContain("add a signer"); + expect(err.name).toBe("WalletNotConnectedError"); + }); + + it("WebAuthnError carries its cause", () => { + const cause = new Error("user cancelled"); + const err = new WebAuthnError( + "registration failed", + PasskeyKitErrorCode.WEBAUTHN_REGISTRATION_FAILED, + cause + ); + expect(err.cause).toBe(cause); + expect(err.code).toBe(PasskeyKitErrorCode.WEBAUTHN_REGISTRATION_FAILED); + }); + + it("ContractError exposes the raw contract code, name, and family", () => { + const err = new ContractError(4, "SignerExpired", "SmartWallet", "expired"); + expect(err.code).toBe(PasskeyKitErrorCode.CONTRACT_ERROR); + expect(err.contractCode).toBe(4); + expect(err.contractErrorName).toBe("SignerExpired"); + expect(err.family).toBe("SmartWallet"); + expect(err.context).toMatchObject({ + contractCode: 4, + contractErrorName: "SignerExpired", + }); + }); +}); + +describe("wrapError", () => { + it("passes through an existing PasskeyKitError", () => { + const original = new ValidationError("bad"); + expect(wrapError(original)).toBe(original); + }); + + it("wraps a plain Error, preserving message and cause", () => { + const plain = new Error("kaboom"); + const wrapped = wrapError(plain, PasskeyKitErrorCode.SIMULATION_FAILED); + expect(wrapped).toBeInstanceOf(PasskeyKitError); + expect(wrapped.message).toBe("kaboom"); + expect(wrapped.code).toBe(PasskeyKitErrorCode.SIMULATION_FAILED); + expect(wrapped.cause).toBe(plain); + }); + + it("wraps a non-Error value with its string form", () => { + const wrapped = wrapError("just a string"); + expect(wrapped.message).toBe("just a string"); + expect(wrapped.code).toBe(PasskeyKitErrorCode.INVALID_INPUT); + expect(wrapped.cause).toBeUndefined(); + }); +}); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..6e79943 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,346 @@ +/** + * Typed error model for the Passkey Kit SDK. + * + * Every error the kit throws is a {@link PasskeyKitError} (or subclass) carrying + * a numeric {@link PasskeyKitErrorCode}, optional structured `context`, and an + * optional `cause`. Callers branch on `error.code` (or `instanceof`) instead of + * matching on message strings. + * + * Submission methods are the one exception to "throw on failure": they return a + * discriminated {@link import("./types.js").TransactionResult} whose failure arm + * carries one of these typed errors (see `contract-errors.ts`). + * + * @packageDocumentation + */ + +/** + * Error codes for Passkey Kit operations, grouped by concern. + * + * Ranges (per SDK architecture spec #599 §5, extended with validation/storage): + * - 1xxx configuration + * - 2xxx wallet state + * - 3xxx WebAuthn + * - 4xxx signing + * - 5xxx transaction (simulation/submission) + * - 6xxx indexer + * - 7xxx relayer + * - 8xxx validation + * - 9xxx storage + * - 10000 contract-level failure decoded from on-chain diagnostics (the raw + * contract code 1-9 is carried separately on {@link ContractError}). + */ +export enum PasskeyKitErrorCode { + // Configuration (1xxx) + INVALID_CONFIG = 1001, + MISSING_CONFIG = 1002, + + // Wallet state (2xxx) + WALLET_NOT_CONNECTED = 2001, + WALLET_ALREADY_EXISTS = 2002, + WALLET_NOT_FOUND = 2003, + WALLET_OWNERSHIP_MISMATCH = 2004, + + // WebAuthn (3xxx) + WEBAUTHN_REGISTRATION_FAILED = 3001, + WEBAUTHN_AUTHENTICATION_FAILED = 3002, + WEBAUTHN_NOT_SUPPORTED = 3003, + WEBAUTHN_CANCELLED = 3004, + PUBLIC_KEY_EXTRACTION_FAILED = 3005, + + // Signing (4xxx) + SIGNING_FAILED = 4001, + SIGNER_NOT_FOUND = 4002, + INVALID_SIGNER = 4003, + INVALID_SIGNATURE_EXPIRATION = 4004, + UNSUPPORTED_CREDENTIALS = 4005, + + // Transaction (5xxx) + SIMULATION_FAILED = 5001, + SUBMISSION_FAILED = 5002, + TRANSACTION_TIMEOUT = 5003, + RESTORE_REQUIRED = 5004, + + // Indexer (6xxx) + INDEXER_NOT_CONFIGURED = 6001, + INDEXER_REQUEST_FAILED = 6002, + + // Relayer (7xxx) + RELAYER_NOT_CONFIGURED = 7001, + RELAYER_REQUEST_FAILED = 7002, + + // Validation (8xxx) + INVALID_ADDRESS = 8001, + INVALID_AMOUNT = 8002, + INVALID_INPUT = 8003, + INVALID_PUBLIC_KEY = 8004, + + // Storage (9xxx) + STORAGE_READ_FAILED = 9001, + STORAGE_WRITE_FAILED = 9002, + + // Contract-level failure decoded from on-chain diagnostics (10xxx). + // The raw contract code (1-9) is carried separately on ContractError so the + // SDK code space never collides with the contract's own small code range. + CONTRACT_ERROR = 10000, +} + +/** + * Base error class for all Passkey Kit errors. + */ +export class PasskeyKitError extends Error { + /** Error code for programmatic handling. */ + readonly code: PasskeyKitErrorCode; + + /** Structured context about the error. */ + readonly context?: Record; + + /** The underlying error that caused this one, if any. */ + readonly cause?: Error; + + constructor( + message: string, + code: PasskeyKitErrorCode, + options?: { + context?: Record; + cause?: Error; + } + ) { + super(message); + this.name = "PasskeyKitError"; + this.code = code; + this.context = options?.context; + this.cause = options?.cause; + + // Maintain a proper stack trace in V8 environments. + if (Error.captureStackTrace) { + Error.captureStackTrace(this, PasskeyKitError); + } + } + + /** Format the error with its code, context, and cause for logging. */ + toDetailedString(): string { + let msg = `[${this.code}] ${this.message}`; + if (this.context) { + msg += `\nContext: ${JSON.stringify(this.context, null, 2)}`; + } + if (this.cause) { + msg += `\nCaused by: ${this.cause.message}`; + } + return msg; + } +} + +/** Thrown when the client is misconfigured. */ +export class ConfigurationError extends PasskeyKitError { + constructor( + message: string, + code: + | PasskeyKitErrorCode.INVALID_CONFIG + | PasskeyKitErrorCode.MISSING_CONFIG = PasskeyKitErrorCode.INVALID_CONFIG, + context?: Record + ) { + super(message, code, { context }); + this.name = "ConfigurationError"; + } +} + +/** Thrown when an operation requires a connected wallet but none is connected. */ +export class WalletNotConnectedError extends PasskeyKitError { + constructor(operation?: string) { + super( + operation + ? `A connected wallet is required to ${operation}` + : "Wallet not connected", + PasskeyKitErrorCode.WALLET_NOT_CONNECTED, + { context: operation ? { operation } : undefined } + ); + this.name = "WalletNotConnectedError"; + } +} + +/** + * Thrown when a connected wallet does not actually own the credential used to + * connect (connectWallet ownership verification, #601 F7). + */ +export class WalletOwnershipError extends PasskeyKitError { + constructor(message: string, context?: Record) { + super(message, PasskeyKitErrorCode.WALLET_OWNERSHIP_MISMATCH, { context }); + this.name = "WalletOwnershipError"; + } +} + +/** Thrown when a WebAuthn ceremony fails. */ +export class WebAuthnError extends PasskeyKitError { + constructor( + message: string, + code: + | PasskeyKitErrorCode.WEBAUTHN_REGISTRATION_FAILED + | PasskeyKitErrorCode.WEBAUTHN_AUTHENTICATION_FAILED + | PasskeyKitErrorCode.WEBAUTHN_NOT_SUPPORTED + | PasskeyKitErrorCode.WEBAUTHN_CANCELLED + | PasskeyKitErrorCode.PUBLIC_KEY_EXTRACTION_FAILED, + cause?: Error + ) { + super(message, code, { cause }); + this.name = "WebAuthnError"; + } +} + +/** Thrown when signing an authorization entry fails. */ +export class SigningError extends PasskeyKitError { + constructor( + message: string, + code: + | PasskeyKitErrorCode.SIGNING_FAILED + | PasskeyKitErrorCode.INVALID_SIGNER + | PasskeyKitErrorCode.INVALID_SIGNATURE_EXPIRATION + | PasskeyKitErrorCode.UNSUPPORTED_CREDENTIALS = PasskeyKitErrorCode.SIGNING_FAILED, + context?: Record + ) { + super(message, code, { context }); + this.name = "SigningError"; + } +} + +/** Thrown when a requested signer cannot be found. */ +export class SignerNotFoundError extends PasskeyKitError { + constructor(identifier: string, hint?: string) { + super( + hint + ? `No signer found for: ${identifier}. ${hint}` + : `No signer found for: ${identifier}`, + PasskeyKitErrorCode.SIGNER_NOT_FOUND, + { context: { identifier } } + ); + this.name = "SignerNotFoundError"; + } +} + +/** Thrown when transaction simulation fails. */ +export class SimulationError extends PasskeyKitError { + constructor(message: string, context?: Record) { + super(message, PasskeyKitErrorCode.SIMULATION_FAILED, { context }); + this.name = "SimulationError"; + } +} + +/** Thrown when transaction submission fails. */ +export class SubmissionError extends PasskeyKitError { + constructor(message: string, hash?: string, context?: Record) { + super(message, PasskeyKitErrorCode.SUBMISSION_FAILED, { + context: { hash, ...context }, + }); + this.name = "SubmissionError"; + } +} + +/** Thrown when input validation fails. */ +export class ValidationError extends PasskeyKitError { + constructor( + message: string, + code: + | PasskeyKitErrorCode.INVALID_ADDRESS + | PasskeyKitErrorCode.INVALID_AMOUNT + | PasskeyKitErrorCode.INVALID_INPUT + | PasskeyKitErrorCode.INVALID_PUBLIC_KEY = PasskeyKitErrorCode.INVALID_INPUT, + context?: Record + ) { + super(message, code, { context }); + this.name = "ValidationError"; + } +} + +/** Thrown when an indexer request fails or the indexer is not configured. */ +export class IndexerError extends PasskeyKitError { + constructor( + message: string, + code: + | PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED + | PasskeyKitErrorCode.INDEXER_REQUEST_FAILED = PasskeyKitErrorCode.INDEXER_REQUEST_FAILED, + context?: Record, + cause?: Error + ) { + super(message, code, { context, cause }); + this.name = "IndexerError"; + } +} + +/** Thrown when a relayer request fails or the relayer is not configured. */ +export class RelayerError extends PasskeyKitError { + constructor( + message: string, + code: + | PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED + | PasskeyKitErrorCode.RELAYER_REQUEST_FAILED = PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, + context?: Record, + cause?: Error + ) { + super(message, code, { context, cause }); + this.name = "RelayerError"; + } +} + +/** + * Error decoded from an on-chain smart-wallet failure code. + * + * Produced by `decodeContractError` when a simulation/submission diagnostic + * reports an `Error(Contract, #N)`. Carries the raw contract code and its enum + * variant name so callers can branch on the exact failure. + * + * @example + * ```typescript + * const result = await server.send(tx); + * if (!result.success && result.error instanceof ContractError) { + * if (result.error.contractErrorName === "SignerExpired") { ... } + * } + * ``` + */ +export class ContractError extends PasskeyKitError { + /** Raw contract error code (e.g. 4). */ + readonly contractCode: number; + + /** Enum variant name from the contract (e.g. "SignerExpired"). */ + readonly contractErrorName: string; + + /** Contract family the code belongs to (e.g. "SmartWallet"). */ + readonly family: string; + + constructor( + contractCode: number, + contractErrorName: string, + family: string, + message: string, + options?: { + context?: Record; + cause?: Error; + } + ) { + super(message, PasskeyKitErrorCode.CONTRACT_ERROR, { + context: { contractCode, contractErrorName, family, ...options?.context }, + cause: options?.cause, + }); + this.name = "ContractError"; + this.contractCode = contractCode; + this.contractErrorName = contractErrorName; + this.family = family; + } +} + +/** + * Wrap an unknown thrown value in a {@link PasskeyKitError}. + * + * Passes through values that are already {@link PasskeyKitError}s. + */ +export function wrapError( + err: unknown, + defaultCode: PasskeyKitErrorCode = PasskeyKitErrorCode.INVALID_INPUT +): PasskeyKitError { + if (err instanceof PasskeyKitError) { + return err; + } + + const message = err instanceof Error ? err.message : String(err); + const cause = err instanceof Error ? err : undefined; + + return new PasskeyKitError(message, defaultCode, { cause }); +} diff --git a/src/events.test.ts b/src/events.test.ts new file mode 100644 index 0000000..0e9a806 --- /dev/null +++ b/src/events.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { PasskeyEventEmitter } from "./events.js"; +import { SignerStore } from "./types.js"; + +describe("PasskeyEventEmitter", () => { + it("delivers events to subscribers and returns an unsubscribe function", () => { + const emitter = new PasskeyEventEmitter(); + const listener = vi.fn(); + + const off = emitter.on("walletConnected", listener); + emitter.emit("walletConnected", { contractId: "C1", keyId: "k1" }); + expect(listener).toHaveBeenCalledWith({ contractId: "C1", keyId: "k1" }); + + off(); + emitter.emit("walletConnected", { contractId: "C2", keyId: "k2" }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("once() fires exactly once", () => { + const emitter = new PasskeyEventEmitter(); + const listener = vi.fn(); + emitter.once("walletDisconnected", listener); + emitter.emit("walletDisconnected", { contractId: "C1" }); + emitter.emit("walletDisconnected", { contractId: "C1" }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("off() removes a specific listener", () => { + const emitter = new PasskeyEventEmitter(); + const a = vi.fn(); + const b = vi.fn(); + emitter.on("signerAdded", a); + emitter.on("signerAdded", b); + emitter.off("signerAdded", a); + emitter.emit("signerAdded", { + contractId: "C1", + kind: "Ed25519", + storage: SignerStore.Persistent, + }); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + }); + + it("isolates a throwing listener and routes its error to the handler", () => { + const emitter = new PasskeyEventEmitter(); + const errorHandler = vi.fn(); + emitter.setErrorHandler(errorHandler); + + const good = vi.fn(); + emitter.on("transactionSubmitted", () => { + throw new Error("bad listener"); + }); + emitter.on("transactionSubmitted", good); + + emitter.emit("transactionSubmitted", { hash: "h1", success: true }); + expect(good).toHaveBeenCalledTimes(1); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0]![0]).toBe("transactionSubmitted"); + }); + + it("tracks listener counts and clears all listeners", () => { + const emitter = new PasskeyEventEmitter(); + emitter.on("walletCreated", vi.fn()); + emitter.on("walletCreated", vi.fn()); + expect(emitter.listenerCount("walletCreated")).toBe(2); + emitter.removeAllListeners("walletCreated"); + expect(emitter.listenerCount("walletCreated")).toBe(0); + }); +}); diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..297d7ed --- /dev/null +++ b/src/events.ts @@ -0,0 +1,169 @@ +/** + * Typed event emitter for passkey / wallet lifecycle events. + * + * `on()` returns an unsubscribe function; a listener that throws is isolated so + * it never prevents the other listeners from running. + * + * @packageDocumentation + */ + +import type { SignerStore } from "./types.js"; + +/** + * The events the kit can emit, mapped to their payload shapes. + */ +export type PasskeyEventMap = { + /** A new wallet was deployed. */ + walletCreated: { contractId: string; keyId: string }; + + /** An existing wallet was connected. */ + walletConnected: { contractId: string; keyId: string }; + + /** The connected wallet was disconnected. */ + walletDisconnected: { contractId: string }; + + /** A signer was added to the connected wallet. */ + signerAdded: { + contractId: string; + kind: "Policy" | "Ed25519" | "Secp256r1"; + storage: SignerStore; + }; + + /** A signer was updated on the connected wallet. */ + signerUpdated: { + contractId: string; + kind: "Policy" | "Ed25519" | "Secp256r1"; + storage: SignerStore; + }; + + /** A signer was removed from the connected wallet. */ + signerRemoved: { + contractId: string; + kind: "Policy" | "Ed25519" | "Secp256r1"; + }; + + /** An authorization entry was signed. */ + transactionSigned: { contractId: string; keyId?: string }; + + /** A transaction was submitted. */ + transactionSubmitted: { hash: string; success: boolean }; +}; + +/** The set of event names the kit emits. */ +export type PasskeyEvent = keyof PasskeyEventMap; + +/** An event listener. */ +export type EventListener = (data: T) => void; + +/** + * Default handler for errors thrown by listeners: log to the console so a + * misbehaving listener is visible rather than silently swallowed. + */ +function defaultListenerErrorHandler( + event: PasskeyEvent, + error: unknown +): void { + console.error(`[PasskeyKit] Listener for "${event}" threw:`, error); +} + +/** + * A small typed event emitter for passkey lifecycle events. + * + * @example + * ```typescript + * const events = new PasskeyEventEmitter(); + * const off = events.on("walletConnected", ({ contractId }) => { + * console.log("connected", contractId); + * }); + * events.emit("walletConnected", { contractId: "C…", keyId: "…" }); + * off(); + * ``` + */ +export class PasskeyEventEmitter { + private listeners: Map< + PasskeyEvent, + Set> + > = new Map(); + + private errorHandler: + | ((event: PasskeyEvent, error: unknown) => void) + | undefined = defaultListenerErrorHandler; + + /** + * Set the handler invoked when a listener throws. Pass `undefined` to silence + * listener errors entirely. + */ + setErrorHandler( + handler: ((event: PasskeyEvent, error: unknown) => void) | undefined + ): void { + this.errorHandler = handler; + } + + /** Subscribe to an event. Returns an unsubscribe function. */ + on( + event: E, + listener: EventListener + ): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + const listeners = this.listeners.get(event)!; + listeners.add( + listener as EventListener + ); + return () => { + listeners.delete( + listener as EventListener + ); + }; + } + + /** Subscribe to an event, firing at most once. Returns an unsubscribe fn. */ + once( + event: E, + listener: EventListener + ): () => void { + const unsubscribe = this.on(event, (data) => { + unsubscribe(); + listener(data); + }); + return unsubscribe; + } + + /** Unsubscribe a specific listener from an event. */ + off( + event: E, + listener: EventListener + ): void { + this.listeners + .get(event) + ?.delete(listener as EventListener); + } + + /** Emit an event to all subscribers, isolating any that throw. */ + emit(event: E, data: PasskeyEventMap[E]): void { + const listeners = this.listeners.get(event); + if (!listeners) return; + for (const listener of listeners) { + try { + listener(data); + } catch (err) { + this.errorHandler?.(event, err); + } + } + } + + /** Remove all listeners for an event, or for every event when omitted. */ + removeAllListeners(event?: PasskeyEvent): void { + if (event) { + this.listeners.delete(event); + } else { + this.listeners.clear(); + } + } + + /** Number of listeners registered for an event. */ + listenerCount(event: PasskeyEvent): number { + return this.listeners.get(event)?.size ?? 0; + } +} diff --git a/src/index.ts b/src/index.ts index 80383f6..ec6b369 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,89 @@ -export { SignerStore, SignerKey, type Signer, type SignerLimits } from './types' -export { PasskeyKit } from "./kit" -export { PasskeyServer } from "./server" -export { SACClient } from "./sac" -export { Client as PasskeyClient } from 'passkey-kit-sdk' \ No newline at end of file +/** + * Passkey Kit — create and use smart-wallet accounts on Stellar with WebAuthn + * passkeys. + * + * Storage adapters live under the `passkey-kit/storage` subpath; the server-only + * entrypoint lives under `passkey-kit/server`. + * + * @packageDocumentation + */ + +// Core clients +export { PasskeyKit } from "./kit.js"; +export { PasskeyServer } from "./server.js"; +export { SACClient } from "./sac.js"; + +// Generated contract client +export { Client as PasskeyClient } from "passkey-kit-sdk"; + +// Signer-key helpers, indexer row, storage + result types +export { + SignerKey, + SignerStore, + type SignerKeyTag, + type SignerLimits, + type IndexedSigner, + type StoredPasskey, + type StorageAdapter, + type CreateWalletResult, + type ConnectWalletResult, + type TransactionResult, + type TransactionSuccess, + type TransactionFailure, + type SubmissionMethod, +} from "./types.js"; + +// Typed errors +export { + PasskeyKitError, + PasskeyKitErrorCode, + ConfigurationError, + WalletNotConnectedError, + WalletOwnershipError, + WebAuthnError, + SigningError, + SignerNotFoundError, + SimulationError, + SubmissionError, + ValidationError, + IndexerError, + RelayerError, + ContractError, + wrapError, +} from "./errors.js"; + +// Contract-error decoding +export { + CONTRACT_ERROR_REGISTRY, + contractErrorFromCode, + decodeContractError, + type ContractErrorFamily, + type ContractErrorInfo, +} from "./contract-errors.js"; + +// Events +export { + PasskeyEventEmitter, + type PasskeyEvent, + type PasskeyEventMap, + type EventListener, +} from "./events.js"; + +// Crypto / derivation helpers +export { + deriveContractAddress, + extractPublicKeyFromAttestation, + compactSignature, + generateChallenge, +} from "./utils.js"; + +// Client-side validation +export { + validateAddress, + validateAmount, + validateExpiration, + validateSecp256r1PublicKey, +} from "./validation.js"; + +// Package metadata +export { VERSION, NAME } from "./version.js"; diff --git a/src/kit.ts b/src/kit.ts index f8ecfea..d9343e1 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -3,9 +3,9 @@ import * as StellarSdk from '@stellar/stellar-sdk' import { StrKey, hash, xdr, Keypair, Address, TransactionBuilder, Operation } from '@stellar/stellar-sdk' import { startRegistration, startAuthentication, type AuthenticationResponseJSON, type AuthenticatorAttestationResponseJSON, type AuthenticatorSelectionCriteria } from "@simplewebauthn/browser" import { Buffer } from 'buffer' -import base64url from 'base64url' -import type { SignerKey, SignerLimits, SignerStore } from './types' -import { PasskeyBase } from './base' +import base64url from './base64url.js' +import type { SignerKey, SignerLimits, SignerStore } from './types.js' +import { PasskeyBase } from './base.js' import { AssembledTransaction, basicNodeSigner, type AssembledTransactionOptions, type Spec as ContractSpec, type Tx } from '@stellar/stellar-sdk/contract' import type { Server } from '@stellar/stellar-sdk/rpc' diff --git a/src/kit/auth-payload.test.ts b/src/kit/auth-payload.test.ts new file mode 100644 index 0000000..166b268 --- /dev/null +++ b/src/kit/auth-payload.test.ts @@ -0,0 +1,188 @@ +/** + * Ported from the old `bun_tests/9.index.ts` Protocol-27 auth-helper vectors. + * + * The old test also exercised the "does this SDK support P27?" fallback shims; + * on stellar-sdk >= 16 those are dead, so this keeps the golden-vector coverage + * (address / addressV2 / with-delegates preimages match the SDK's own + * `buildAuthorizationEntryPreimage`) and drops the shim branches. + */ + +import { describe, expect, it } from "vitest"; +import { + Address, + Keypair, + Networks, + buildAuthorizationEntryPreimage, + buildWithDelegatesEntry, + hash, + xdr, +} from "@stellar/stellar-sdk"; +import { + assertSignatureExpirationLedger, + buildSignaturePayload, + getAddressCredentials, + usesAddressBoundPayload, +} from "./auth-payload.js"; + +const networkPassphrase = Networks.TESTNET; + +function makeAccount(seedByte: number): string { + return Keypair.fromRawEd25519Seed(Buffer.alloc(32, seedByte)).publicKey(); +} + +function makeInvocation(): xdr.SorobanAuthorizedInvocation { + return new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: xdr.ScAddress.scAddressTypeContract( + hash(Buffer.from("contract")) + ), + functionName: "do_it", + args: [], + }) + ), + subInvocations: [], + }); +} + +function makeAddressCredentials( + address: string, + expiration = 1 +): xdr.SorobanAddressCredentials { + return new xdr.SorobanAddressCredentials({ + address: Address.fromString(address).toScAddress(), + nonce: xdr.Int64.fromString("7"), + signatureExpirationLedger: expiration, + signature: xdr.ScVal.scvVoid(), + }); +} + +function makeAuthEntry(address: string): xdr.SorobanAuthorizationEntry { + return new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( + makeAddressCredentials(address) + ), + rootInvocation: makeInvocation(), + }); +} + +describe("getAddressCredentials", () => { + it("returns the address credentials for a plain address entry", () => { + const account = makeAccount(1); + const entry = makeAuthEntry(account); + const credentials = getAddressCredentials(entry.credentials()); + + expect(entry.credentials().switch().name).toBe("sorobanCredentialsAddress"); + expect(Address.fromScAddress(credentials.address()).toString()).toBe(account); + expect(usesAddressBoundPayload(entry.credentials())).toBe(false); + }); + + it("throws for non-address (source-account) credentials", () => { + const sourceAccount = xdr.SorobanCredentials.sorobanCredentialsSourceAccount(); + expect(() => getAddressCredentials(sourceAccount)).toThrow( + /do not contain address credentials/ + ); + }); +}); + +describe("buildSignaturePayload", () => { + it("matches the SDK preimage hash for a plain address entry", () => { + const entry = makeAuthEntry(makeAccount(2)); + const payload = buildSignaturePayload(networkPassphrase, entry, 123); + const credentials = getAddressCredentials(entry.credentials()); + + const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR()); + const expectedPreimage = buildAuthorizationEntryPreimage( + expectedEntry, + 123, + networkPassphrase + ); + + expect(payload).toEqual(hash(expectedPreimage.toXDR())); + expect(credentials.signatureExpirationLedger()).toBe(123); + }); + + it("matches the SDK preimage hash for an addressV2 (address-bound) entry", () => { + const baseEntry = makeAuthEntry(makeAccount(3)); + const credentials = getAddressCredentials(baseEntry.credentials()); + const addressV2Entry = new xdr.SorobanAuthorizationEntry({ + credentials: + xdr.SorobanCredentials.sorobanCredentialsAddressV2(credentials), + rootInvocation: baseEntry.rootInvocation(), + }); + + expect(addressV2Entry.credentials().switch().name).toBe( + "sorobanCredentialsAddressV2" + ); + expect(usesAddressBoundPayload(addressV2Entry.credentials())).toBe(true); + + const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR( + addressV2Entry.toXDR() + ); + const expectedPreimage = buildAuthorizationEntryPreimage( + expectedEntry, + 123, + networkPassphrase + ); + + expect(buildSignaturePayload(networkPassphrase, addressV2Entry, 123)).toEqual( + hash(expectedPreimage.toXDR()) + ); + }); + + it("matches the SDK preimage hash for a with-delegates entry", () => { + const delegatedEntry = buildWithDelegatesEntry({ + entry: makeAuthEntry(makeAccount(4)), + validUntilLedgerSeq: 123, + delegates: [{ address: makeAccount(5) }], + }); + + expect(delegatedEntry.credentials().switch().name).toBe( + "sorobanCredentialsAddressWithDelegates" + ); + expect(usesAddressBoundPayload(delegatedEntry.credentials())).toBe(true); + + const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR( + delegatedEntry.toXDR() + ); + const expectedPreimage = buildAuthorizationEntryPreimage( + expectedEntry, + 123, + networkPassphrase + ); + + expect(buildSignaturePayload(networkPassphrase, delegatedEntry, 123)).toEqual( + hash(expectedPreimage.toXDR()) + ); + expect( + getAddressCredentials( + delegatedEntry.credentials() + ).signatureExpirationLedger() + ).toBe(123); + }); + + it("rejects a non-u32 expiration without mutating the entry", () => { + const entry = makeAuthEntry(makeAccount(4)); + const credentials = getAddressCredentials(entry.credentials()); + expect(() => buildSignaturePayload(networkPassphrase, entry, 1.5)).toThrow( + /uint32 integer/ + ); + expect(credentials.signatureExpirationLedger()).toBe(1); + }); +}); + +describe("assertSignatureExpirationLedger", () => { + it("accepts u32 bounds and rejects everything else", () => { + expect(() => assertSignatureExpirationLedger(0)).not.toThrow(); + expect(() => assertSignatureExpirationLedger(0xffffffff)).not.toThrow(); + expect(() => assertSignatureExpirationLedger(-1)).toThrow(/uint32 integer/); + expect(() => assertSignatureExpirationLedger(0x100000000)).toThrow( + /uint32 integer/ + ); + expect(() => assertSignatureExpirationLedger(1.5)).toThrow(/uint32 integer/); + expect(() => assertSignatureExpirationLedger(Number.NaN)).toThrow( + /uint32 integer/ + ); + }); +}); diff --git a/src/kit/auth-payload.ts b/src/kit/auth-payload.ts new file mode 100644 index 0000000..5768da0 --- /dev/null +++ b/src/kit/auth-payload.ts @@ -0,0 +1,107 @@ +/** + * Soroban authorization-entry payload helpers. + * + * These are thin wrappers over the stellar-sdk's native Protocol-27 auth + * primitives (`buildAuthorizationEntryPreimage`). The old kit hand-rolled a + * stack of "does this SDK support P27?" probe shims; on stellar-sdk >= 16 those + * are all dead, so this module simply uses the SDK. + * + * This is the foundational slice (B1). The full signing pipeline — the + * host-ordered `compareScVal` Signatures-map merge and Udt encoding — is layered + * on top in B2. + * + * @packageDocumentation + */ + +import { + buildAuthorizationEntryPreimage, + hash, + xdr, +} from "@stellar/stellar-sdk"; +import { SigningError, PasskeyKitErrorCode } from "../errors.js"; + +/** + * Extract the {@link xdr.SorobanAddressCredentials} from a credentials union, + * for the address / addressV2 / addressWithDelegates variants. + * + * @throws {SigningError} If the credentials are not address-based. + */ +export function getAddressCredentials( + credentials: xdr.SorobanCredentials +): xdr.SorobanAddressCredentials { + switch (credentials.switch().name) { + case "sorobanCredentialsAddress": + return credentials.address(); + case "sorobanCredentialsAddressV2": + return credentials.addressV2(); + case "sorobanCredentialsAddressWithDelegates": + return credentials.addressWithDelegates().addressCredentials(); + default: + throw new SigningError( + `Soroban credentials do not contain address credentials: ${credentials.switch().name}`, + PasskeyKitErrorCode.UNSUPPORTED_CREDENTIALS + ); + } +} + +/** + * Whether a credentials union uses an address-bound signature payload (the P27 + * V2 / with-delegates variants), whose preimage binds the invoker address. + */ +export function usesAddressBoundPayload( + credentials: xdr.SorobanCredentials +): boolean { + const name = credentials.switch().name; + return ( + name === "sorobanCredentialsAddressV2" || + name === "sorobanCredentialsAddressWithDelegates" + ); +} + +/** + * Assert that a signature-expiration ledger is a valid `u32`. + * + * @throws {SigningError} If not a `u32` integer. + */ +export function assertSignatureExpirationLedger(expiration: number): void { + if ( + !Number.isInteger(expiration) || + expiration < 0 || + expiration > 0xffffffff + ) { + throw new SigningError( + "Soroban signature expiration ledger must be a uint32 integer", + PasskeyKitErrorCode.INVALID_SIGNATURE_EXPIRATION, + { expiration } + ); + } +} + +/** + * Build the signature payload (the 32-byte hash a signer signs) for an + * authorization entry at a given expiration ledger. + * + * Sets the expiration on the entry's address credentials, then hashes the SDK's + * canonical authorization-entry preimage. Works for the address, addressV2, and + * addressWithDelegates credential variants. + * + * @throws {SigningError} If `expiration` is not a valid `u32`. + */ +export function buildSignaturePayload( + networkPassphrase: string, + entry: xdr.SorobanAuthorizationEntry, + expiration: number +): Buffer { + assertSignatureExpirationLedger(expiration); + + const credentials = getAddressCredentials(entry.credentials()); + credentials.signatureExpirationLedger(expiration); + + return hash( + buildAuthorizationEntryPreimage( + entry, + expiration, + networkPassphrase + ).toXDR() + ); +} diff --git a/src/sac.ts b/src/sac.ts index 8bb04ea..a6165aa 100644 --- a/src/sac.ts +++ b/src/sac.ts @@ -1,5 +1,5 @@ import { Client as SacClient } from 'sac-sdk' -import { PasskeyBase } from "./base" +import { PasskeyBase } from "./base.js" import type { Server } from '@stellar/stellar-sdk/rpc' export class SACClient extends PasskeyBase { diff --git a/src/server.ts b/src/server.ts index 7e99dec..2352002 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,8 +1,8 @@ import { xdr } from "@stellar/stellar-sdk" -import { PasskeyBase } from "./base" -import base64url from "base64url" +import { PasskeyBase } from "./base.js" +import base64url from "./base64url.js" import type { Tx } from "@stellar/stellar-sdk/contract" -import type { Signer } from "./types" +import type { IndexedSigner } from "./types.js" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { Durability } from "@stellar/stellar-sdk/rpc" import { ChannelsClient } from "@openzeppelin/relayer-plugin-channels" @@ -103,7 +103,7 @@ export class PasskeyServer extends PasskeyBase { } } - return signers as Signer[] + return signers as IndexedSigner[] } public async getContractId(options: { diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 0000000..c977778 --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,14 @@ +/** + * Storage adapters for passkey persistence. + * + * Import from the `passkey-kit/storage` subpath: + * ```typescript + * import { IndexedDBStorage, MemoryStorage } from "passkey-kit/storage"; + * ``` + */ + +export { MemoryStorage } from "./memory.js"; +export { LocalStorageAdapter } from "./localStorage.js"; +export { IndexedDBStorage } from "./indexeddb.js"; + +export type { StorageAdapter, StoredPasskey } from "../types.js"; diff --git a/src/storage/indexeddb.test.ts b/src/storage/indexeddb.test.ts new file mode 100644 index 0000000..dd529cb --- /dev/null +++ b/src/storage/indexeddb.test.ts @@ -0,0 +1,68 @@ +import "fake-indexeddb/auto"; +import { afterEach, describe, expect, it } from "vitest"; +import { IndexedDBStorage } from "./indexeddb.js"; +import type { StoredPasskey } from "../types.js"; + +function makePasskey(overrides: Partial = {}): StoredPasskey { + return { + keyId: "key-1", + publicKey: new Uint8Array([0x04, 5, 6, 7]), + contractId: "C1", + createdAt: 1, + ...overrides, + }; +} + +let dbCounter = 0; + +/** A fresh named database per test so cases don't share state. */ +function freshStore(): IndexedDBStorage { + return new IndexedDBStorage(`passkey-kit-test-${dbCounter++}`); +} + +describe("IndexedDBStorage", () => { + const opened: IndexedDBStorage[] = []; + + afterEach(async () => { + await Promise.all(opened.map((s) => s.close())); + opened.length = 0; + }); + + function track(store: IndexedDBStorage): IndexedDBStorage { + opened.push(store); + return store; + } + + it("saves and retrieves a passkey with its Uint8Array intact", async () => { + const store = track(freshStore()); + await store.save(makePasskey()); + const loaded = await store.get("key-1"); + expect(loaded).not.toBeNull(); + expect(loaded!.publicKey).toBeInstanceOf(Uint8Array); + expect(Array.from(loaded!.publicKey)).toEqual([0x04, 5, 6, 7]); + expect(await store.get("missing")).toBeNull(); + }); + + it("queries by contract id via the index", async () => { + const store = track(freshStore()); + await store.save(makePasskey({ keyId: "a", contractId: "CX" })); + await store.save(makePasskey({ keyId: "b", contractId: "CX" })); + await store.save(makePasskey({ keyId: "c", contractId: "CY" })); + const results = await store.getByContract("CX"); + expect(results.map((p) => p.keyId).sort()).toEqual(["a", "b"]); + }); + + it("updates metadata, deletes, and clears", async () => { + const store = track(freshStore()); + await store.save(makePasskey()); + await store.update("key-1", { lastUsedAt: 42 }); + expect((await store.get("key-1"))!.lastUsedAt).toBe(42); + + await store.delete("key-1"); + expect(await store.get("key-1")).toBeNull(); + + await store.save(makePasskey({ keyId: "x" })); + await store.clear(); + expect(await store.getAll()).toEqual([]); + }); +}); diff --git a/src/storage/indexeddb.ts b/src/storage/indexeddb.ts new file mode 100644 index 0000000..14c47d4 --- /dev/null +++ b/src/storage/indexeddb.ts @@ -0,0 +1,143 @@ +/** + * IndexedDB passkey storage adapter. + * + * Recommended for web apps: larger limits than localStorage, an async API that + * doesn't block the main thread, and native `Uint8Array` support. + */ + +import type { StorageAdapter, StoredPasskey } from "../types.js"; +import { + DB_NAME, + DB_VERSION, + IDB_STORE_CREDENTIALS, + IDB_INDEX_CONTRACT_ID, +} from "../constants.js"; + +export class IndexedDBStorage implements StorageAdapter { + private dbName: string; + private dbPromise: Promise | null = null; + + constructor(dbName: string = DB_NAME) { + this.dbName = dbName; + } + + private async getDB(): Promise { + if (this.dbPromise) return this.dbPromise; + + this.dbPromise = new Promise((resolve, reject) => { + if (typeof indexedDB === "undefined") { + reject(new Error("IndexedDB is not available in this environment")); + return; + } + + const request = indexedDB.open(this.dbName, DB_VERSION); + + request.onerror = () => + reject(new Error(`Failed to open IndexedDB: ${request.error?.message}`)); + + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(IDB_STORE_CREDENTIALS)) { + const store = db.createObjectStore(IDB_STORE_CREDENTIALS, { + keyPath: "keyId", + }); + store.createIndex(IDB_INDEX_CONTRACT_ID, IDB_INDEX_CONTRACT_ID, { + unique: false, + }); + } + }; + }); + + return this.dbPromise; + } + + private async withStore( + mode: IDBTransactionMode, + callback: (store: IDBObjectStore) => IDBRequest + ): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(IDB_STORE_CREDENTIALS, mode); + const store = transaction.objectStore(IDB_STORE_CREDENTIALS); + const request = callback(store); + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject( + new Error(`IndexedDB operation failed: ${request.error?.message}`) + ); + }); + } + + async save(passkey: StoredPasskey): Promise { + await this.withStore("readwrite", (store) => store.put(passkey)); + } + + async get(keyId: string): Promise { + const result = await this.withStore( + "readonly", + (store) => store.get(keyId) + ); + return result ?? null; + } + + async getByContract(contractId: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(IDB_STORE_CREDENTIALS, "readonly"); + const index = transaction + .objectStore(IDB_STORE_CREDENTIALS) + .index(IDB_INDEX_CONTRACT_ID); + const request = index.getAll(contractId); + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject( + new Error(`Failed to query by contract: ${request.error?.message}`) + ); + }); + } + + async getAll(): Promise { + return this.withStore("readonly", (store) => + store.getAll() + ); + } + + async delete(keyId: string): Promise { + await this.withStore("readwrite", (store) => store.delete(keyId)); + } + + async update( + keyId: string, + updates: Partial> + ): Promise { + const existing = await this.get(keyId); + if (existing) { + await this.save({ ...existing, ...updates }); + } + } + + async clear(): Promise { + await this.withStore("readwrite", (store) => store.clear()); + } + + /** Close the database connection. */ + async close(): Promise { + if (this.dbPromise) { + const db = await this.dbPromise; + db.close(); + this.dbPromise = null; + } + } + + /** Delete the entire database. */ + static async deleteDatabase(dbName: string = DB_NAME): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(dbName); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(new Error(`Failed to delete database: ${request.error?.message}`)); + }); + } +} diff --git a/src/storage/localStorage.test.ts b/src/storage/localStorage.test.ts new file mode 100644 index 0000000..85c015a --- /dev/null +++ b/src/storage/localStorage.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { LocalStorageAdapter } from "./localStorage.js"; +import type { StoredPasskey } from "../types.js"; + +/** Minimal in-memory localStorage shim for the Node test environment. */ +class LocalStorageShim { + private store = new Map(); + getItem(key: string): string | null { + return this.store.has(key) ? this.store.get(key)! : null; + } + setItem(key: string, value: string): void { + this.store.set(key, value); + } + removeItem(key: string): void { + this.store.delete(key); + } + clear(): void { + this.store.clear(); + } +} + +function makePasskey(overrides: Partial = {}): StoredPasskey { + return { + keyId: "key-1", + publicKey: new Uint8Array([0x04, 9, 8, 7]), + contractId: "C1", + createdAt: 1, + ...overrides, + }; +} + +describe("LocalStorageAdapter", () => { + beforeEach(() => { + (globalThis as { localStorage?: unknown }).localStorage = + new LocalStorageShim(); + }); + + it("persists a passkey across adapter instances (round-trips Uint8Array)", async () => { + const a = new LocalStorageAdapter(); + await a.save(makePasskey()); + + const b = new LocalStorageAdapter(); + const loaded = await b.get("key-1"); + expect(loaded).not.toBeNull(); + expect(loaded!.publicKey).toBeInstanceOf(Uint8Array); + expect(Array.from(loaded!.publicKey)).toEqual([0x04, 9, 8, 7]); + expect(loaded!.contractId).toBe("C1"); + }); + + it("queries by contract, updates, deletes, and clears", async () => { + const store = new LocalStorageAdapter(); + await store.save(makePasskey({ keyId: "a", contractId: "CX" })); + await store.save(makePasskey({ keyId: "b", contractId: "CY" })); + + expect((await store.getByContract("CX")).map((p) => p.keyId)).toEqual(["a"]); + + await store.update("a", { nickname: "renamed" }); + expect((await store.get("a"))!.nickname).toBe("renamed"); + + await store.delete("a"); + expect(await store.get("a")).toBeNull(); + + await store.clear(); + expect(await store.getAll()).toEqual([]); + }); + + it("treats corrupt storage as empty without throwing", async () => { + (globalThis as { localStorage: LocalStorageShim }).localStorage.setItem( + "passkey-kit:credentials", + "{not valid json" + ); + const store = new LocalStorageAdapter(); + expect(await store.getAll()).toEqual([]); + }); +}); diff --git a/src/storage/localStorage.ts b/src/storage/localStorage.ts new file mode 100644 index 0000000..affee21 --- /dev/null +++ b/src/storage/localStorage.ts @@ -0,0 +1,114 @@ +/** + * `localStorage` passkey storage adapter. + * + * Persists passkey records across page reloads in the browser. `localStorage` + * has a ~5MB limit and stores data unencrypted; for larger or more structured + * needs prefer {@link import("./indexeddb.js").IndexedDBStorage}. + */ + +import type { StorageAdapter, StoredPasskey } from "../types.js"; +import { LOCALSTORAGE_CREDENTIALS_KEY } from "../constants.js"; + +/** + * JSON-serializable form of a passkey record (`publicKey` becomes a number[] + * since `Uint8Array` is not JSON-serializable). + */ +type SerializedPasskey = Omit & { + publicKey: number[]; +}; + +function serialize(passkey: StoredPasskey): SerializedPasskey { + return { ...passkey, publicKey: Array.from(passkey.publicKey) }; +} + +function deserialize(data: SerializedPasskey): StoredPasskey { + return { ...data, publicKey: new Uint8Array(data.publicKey) }; +} + +export class LocalStorageAdapter implements StorageAdapter { + private storageKey: string; + + constructor(storageKey: string = LOCALSTORAGE_CREDENTIALS_KEY) { + this.storageKey = storageKey; + } + + private read(): Map { + if (typeof localStorage === "undefined") { + throw new Error("localStorage is not available in this environment"); + } + + const data = localStorage.getItem(this.storageKey); + if (!data) return new Map(); + + try { + const parsed = JSON.parse(data) as Record; + const map = new Map(); + for (const [key, value] of Object.entries(parsed)) { + map.set(key, deserialize(value)); + } + return map; + } catch (error) { + // Corrupt storage: warn loudly rather than silently dropping the user's + // passkeys. Return empty so the app keeps working; do not overwrite. + console.error( + `[PasskeyKit] Failed to parse stored passkeys at "${this.storageKey}"; treating storage as empty.`, + error + ); + return new Map(); + } + } + + private write(passkeys: Map): void { + const obj: Record = {}; + for (const [key, value] of passkeys.entries()) { + obj[key] = serialize(value); + } + localStorage.setItem(this.storageKey, JSON.stringify(obj)); + } + + async save(passkey: StoredPasskey): Promise { + const passkeys = this.read(); + passkeys.set(passkey.keyId, passkey); + this.write(passkeys); + } + + async get(keyId: string): Promise { + return this.read().get(keyId) ?? null; + } + + async getByContract(contractId: string): Promise { + const results: StoredPasskey[] = []; + for (const passkey of this.read().values()) { + if (passkey.contractId === contractId) { + results.push(passkey); + } + } + return results; + } + + async getAll(): Promise { + return Array.from(this.read().values()); + } + + async delete(keyId: string): Promise { + const passkeys = this.read(); + passkeys.delete(keyId); + this.write(passkeys); + } + + async update( + keyId: string, + updates: Partial> + ): Promise { + const passkeys = this.read(); + const passkey = passkeys.get(keyId); + if (passkey) { + passkeys.set(keyId, { ...passkey, ...updates }); + this.write(passkeys); + } + } + + async clear(): Promise { + localStorage.removeItem(this.storageKey); + } +} diff --git a/src/storage/memory.test.ts b/src/storage/memory.test.ts new file mode 100644 index 0000000..9dbc3ea --- /dev/null +++ b/src/storage/memory.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { MemoryStorage } from "./memory.js"; +import type { StoredPasskey } from "../types.js"; + +function makePasskey(overrides: Partial = {}): StoredPasskey { + return { + keyId: "key-1", + publicKey: new Uint8Array([0x04, 1, 2, 3]), + contractId: "C1", + createdAt: 1, + ...overrides, + }; +} + +describe("MemoryStorage", () => { + it("saves and retrieves a passkey by keyId", async () => { + const store = new MemoryStorage(); + const passkey = makePasskey(); + await store.save(passkey); + expect(await store.get("key-1")).toEqual(passkey); + expect(await store.get("missing")).toBeNull(); + }); + + it("returns a copy, not the stored reference", async () => { + const store = new MemoryStorage(); + await store.save(makePasskey()); + const first = await store.get("key-1"); + first!.nickname = "mutated"; + const second = await store.get("key-1"); + expect(second!.nickname).toBeUndefined(); + }); + + it("queries by contract id", async () => { + const store = new MemoryStorage(); + await store.save(makePasskey({ keyId: "a", contractId: "CX" })); + await store.save(makePasskey({ keyId: "b", contractId: "CX" })); + await store.save(makePasskey({ keyId: "c", contractId: "CY" })); + const results = await store.getByContract("CX"); + expect(results.map((p) => p.keyId).sort()).toEqual(["a", "b"]); + }); + + it("updates mutable metadata and deletes", async () => { + const store = new MemoryStorage(); + await store.save(makePasskey()); + await store.update("key-1", { nickname: "Primary" }); + expect((await store.get("key-1"))!.nickname).toBe("Primary"); + await store.delete("key-1"); + expect(await store.get("key-1")).toBeNull(); + }); + + it("clears all records", async () => { + const store = new MemoryStorage(); + await store.save(makePasskey({ keyId: "a" })); + await store.save(makePasskey({ keyId: "b" })); + await store.clear(); + expect(await store.getAll()).toEqual([]); + }); +}); diff --git a/src/storage/memory.ts b/src/storage/memory.ts new file mode 100644 index 0000000..fd42116 --- /dev/null +++ b/src/storage/memory.ts @@ -0,0 +1,53 @@ +/** + * In-memory passkey storage adapter. + * + * Useful for tests and server-side environments. Data is lost when the process + * restarts. + */ + +import type { StorageAdapter, StoredPasskey } from "../types.js"; + +export class MemoryStorage implements StorageAdapter { + private passkeys: Map = new Map(); + + async save(passkey: StoredPasskey): Promise { + this.passkeys.set(passkey.keyId, { ...passkey }); + } + + async get(keyId: string): Promise { + const passkey = this.passkeys.get(keyId); + return passkey ? { ...passkey } : null; + } + + async getByContract(contractId: string): Promise { + const results: StoredPasskey[] = []; + for (const passkey of this.passkeys.values()) { + if (passkey.contractId === contractId) { + results.push({ ...passkey }); + } + } + return results; + } + + async getAll(): Promise { + return Array.from(this.passkeys.values()).map((p) => ({ ...p })); + } + + async delete(keyId: string): Promise { + this.passkeys.delete(keyId); + } + + async update( + keyId: string, + updates: Partial> + ): Promise { + const passkey = this.passkeys.get(keyId); + if (passkey) { + this.passkeys.set(keyId, { ...passkey, ...updates }); + } + } + + async clear(): Promise { + this.passkeys.clear(); + } +} diff --git a/src/types.ts b/src/types.ts index 984ce3f..974387e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,32 +1,215 @@ -export type Signer = { - kind: string - key: string - val: string - expiration: number | null - storage: "Persistent" | "Temporary" - limits: string - evicted?: boolean -} +/** + * Core type definitions for the Passkey Kit SDK. + * + * @packageDocumentation + */ + +import type { + AuthenticationResponseJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/browser"; +import type { PasskeyKitError } from "./errors.js"; + +// ============================================================================ +// Signer-key helpers (SDK-side mirror of the contract's SignerKey union) +// ============================================================================ +/** + * The three signer kinds the smart wallet understands. + */ +export type SignerKeyTag = "Policy" | "Ed25519" | "Secp256r1"; + +/** + * A convenience wrapper describing which signer a key/limits entry refers to. + * + * The `value` is the string form the caller works with: a `C…`/`G…` address for + * `Policy`, a `G…` public key for `Ed25519`, or a base64url credential id for + * `Secp256r1`. The signing pipeline converts these into the contract's + * `SignerKey` ScVals. + */ export class SignerKey { - private constructor(public key: "Policy" | "Ed25519" | "Secp256r1", public value: string) { } + private constructor( + public key: SignerKeyTag, + public value: string + ) {} - static Policy(policy: string): SignerKey { - return new SignerKey("Policy", policy); - } + static Policy(policy: string): SignerKey { + return new SignerKey("Policy", policy); + } - static Ed25519(publicKey: string): SignerKey { - return new SignerKey("Ed25519", publicKey); - } + static Ed25519(publicKey: string): SignerKey { + return new SignerKey("Ed25519", publicKey); + } - static Secp256r1(id: string): SignerKey { - return new SignerKey("Secp256r1", id); - } + static Secp256r1(keyId: string): SignerKey { + return new SignerKey("Secp256r1", keyId); + } } -export type SignerLimits = Map | undefined +/** + * Per-signer limits: a map of contract address → the signer keys that signer may + * co-authorize on that contract (`undefined` value = unrestricted on that + * contract). `undefined` for the whole map = a fully unlimited signer. + */ +export type SignerLimits = Map | undefined; +/** + * Storage durability for a signer entry. + */ export enum SignerStore { - Persistent = 'Persistent', - Temporary = 'Temporary', -} \ No newline at end of file + Persistent = "Persistent", + Temporary = "Temporary", +} + +// ============================================================================ +// Indexer row (renamed from the old `Signer` to avoid colliding with the +// bindings' `Signer` union — #599) +// ============================================================================ + +/** + * A signer row as returned by an indexer backend (Mercury or Stellar Indexer). + * + * Renamed from the old `Signer` type, which name-collided with the generated + * bindings' `Signer` union. The richer cross-backend shape lives in + * `indexer/types.ts` (`WalletSigner`); this remains the wire-row shape the + * current server helpers return. + */ +export interface IndexedSigner { + kind: string; + key: string; + val: string; + expiration: number | null; + storage: "Persistent" | "Temporary"; + limits: string; + evicted?: boolean; +} + +// ============================================================================ +// Stored passkey records +// ============================================================================ + +/** + * A persisted passkey → smart-wallet association. + * + * The `keyId` (base64url credential id) is essential for re-authenticating the + * passkey later; the `contractId` lets the kit reconnect a wallet from a keyId + * without an indexer round-trip. + */ +export interface StoredPasskey { + /** Base64URL-encoded credential id (the passkey keyId) — the unique key. */ + keyId: string; + + /** 65-byte uncompressed secp256r1 public key (0x04 prefix + x + y). */ + publicKey: Uint8Array; + + /** Smart-wallet contract address this passkey controls. */ + contractId: string; + + /** User-friendly label for this passkey. */ + nickname?: string; + + /** Unix-ms timestamp when the passkey was created. */ + createdAt: number; + + /** Unix-ms timestamp when the passkey was last used. */ + lastUsedAt?: number; +} + +/** + * Interface for passkey storage adapters. + * + * Implementations persist the passkey → wallet association in various backends + * (IndexedDB, localStorage, memory). See `./storage`. + */ +export interface StorageAdapter { + /** Save a new passkey record or overwrite an existing one (keyed by keyId). */ + save(passkey: StoredPasskey): Promise; + + /** Get a passkey record by its keyId. */ + get(keyId: string): Promise; + + /** Get all passkey records associated with a contract. */ + getByContract(contractId: string): Promise; + + /** Get every stored passkey record. */ + getAll(): Promise; + + /** Delete a passkey record by its keyId. */ + delete(keyId: string): Promise; + + /** Update mutable metadata on a stored passkey record. */ + update( + keyId: string, + updates: Partial> + ): Promise; + + /** Remove every stored passkey record. */ + clear(): Promise; +} + +// ============================================================================ +// Result types +// ============================================================================ + +/** Result of creating (deploying) a new smart wallet. */ +export interface CreateWalletResult { + /** The raw WebAuthn registration response. */ + rawResponse: RegistrationResponseJSON; + + /** Raw credential id bytes. */ + keyId: Uint8Array; + + /** Base64URL-encoded credential id. */ + keyIdBase64: string; + + /** Deployed smart-wallet contract address. */ + contractId: string; + + /** Signed deployment transaction, ready to submit (base64 XDR). */ + signedTx: string; +} + +/** Result of connecting to an existing smart wallet. */ +export interface ConnectWalletResult { + /** Raw WebAuthn authentication response, when a ceremony was performed. */ + rawResponse?: AuthenticationResponseJSON; + + /** Raw credential id bytes. */ + keyId: Uint8Array; + + /** Base64URL-encoded credential id. */ + keyIdBase64: string; + + /** Connected smart-wallet contract address. */ + contractId: string; +} + +/** Successful result of a submission operation. */ +export interface TransactionSuccess { + success: true; + /** Transaction hash. */ + hash: string; + /** Ledger the transaction was included in, when known. */ + ledger?: number; +} + +/** + * Failed result of a submission operation. + * + * Submission methods never throw for expected on-chain/relayer failures; they + * return this shape with a typed {@link PasskeyKitError} (a `ContractError` when + * an on-chain contract code was decoded). All other SDK methods throw. + */ +export interface TransactionFailure { + success: false; + /** The typed error describing the failure. Branch on `error.code`. */ + error: PasskeyKitError; + /** Transaction hash, when one was assigned before the failure. */ + hash?: string; +} + +/** Result of a submission operation: a discriminated union on `success`. */ +export type TransactionResult = TransactionSuccess | TransactionFailure; + +/** How a transaction is submitted to the network. */ +export type SubmissionMethod = "relayer" | "rpc"; diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..cf2277e --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import base64url from "./base64url.js"; +import { + compactSignature, + deriveContractAddress, + extractPublicKeyFromAttestation, + generateChallenge, +} from "./utils.js"; +import { SECP256R1_CURVE_ORDER } from "./constants.js"; + +const TESTNET = "Test SDF Network ; September 2015"; +const MAINNET = "Public Global Stellar Network ; September 2015"; + +// The canonical passkey-kit deployer: Keypair.fromRawEd25519Seed(sha256("kalepail")). +const DEPLOYER = "GC2C7AWLS2FMFTQAHW3IBUB4ZXVP4E37XNLEF2IK7IVXBB6CMEPCSXFO"; + +// Golden vectors pinning the deterministic contract-address derivation +// (salt = sha256(keyId), deployer = the canonical kalepail keypair). Any change +// to the derivation breaks these instead of silently shifting deployed wallet +// addresses — and breaking indexer reverse-lookup. +const DERIVE_VECTORS = [ + { name: "zeros-16", credHex: "00".repeat(16), passphrase: TESTNET, expected: "CD7HUBQP46MGSX3J2U5VLMU3UCAKO7KWFGUCZZQXJCOXCWRMSDDK6J2A" }, + { name: "zeros-32", credHex: "00".repeat(32), passphrase: TESTNET, expected: "CBQU3NIOXC3IDGERJWV3YVMSQSIOU2S6NSMH35OS3GPG6XARZFAAT2NL" }, + { name: "ff-16", credHex: "ff".repeat(16), passphrase: TESTNET, expected: "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL" }, + { name: "random-16", credHex: "0123456789abcdef0123456789abcdef", passphrase: TESTNET, expected: "CARFE5CAZUVN7NWEYKQHFD55SXPJ2IZNT3HQDI22PKNTLOHTAP6DENDJ" }, + { name: "random-32", credHex: "deadbeefcafef00dfeedfacebaadf00d0123456789abcdef0123456789abcdef", passphrase: TESTNET, expected: "CAWJ3E3FTZPDQPHWYNRTDAWPW6SEUV4TFPIGW2VOSZHILKQG4N7JHK3P" }, + { name: "mainnet-random-16", credHex: "0123456789abcdef0123456789abcdef", passphrase: MAINNET, expected: "CBTNGSWMKSGVQ5YGEUHG2YJIZEGSTBZKSVUMWXSRODNQ732JRC5AYV6Z" }, +] as const; + +describe("deriveContractAddress", () => { + it.each(DERIVE_VECTORS)( + "derives $name to the pinned contract address", + ({ credHex, passphrase, expected }) => { + const keyId = Buffer.from(credHex, "hex"); + expect(deriveContractAddress(keyId, DEPLOYER, passphrase)).toBe(expected); + } + ); + + it("derives a different address on mainnet than testnet for the same keyId", () => { + const keyId = Buffer.from("0123456789abcdef0123456789abcdef", "hex"); + expect(deriveContractAddress(keyId, DEPLOYER, TESTNET)).not.toBe( + deriveContractAddress(keyId, DEPLOYER, MAINNET) + ); + }); +}); + +describe("extractPublicKeyFromAttestation", () => { + it("normalizes an SPKI public key from a WebAuthn registration response", async () => { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"] + ); + + const spki = Buffer.from( + new Uint8Array(await crypto.subtle.exportKey("spki", keyPair.publicKey)) + ); + const raw = Buffer.from( + new Uint8Array(await crypto.subtle.exportKey("raw", keyPair.publicKey)) + ); + + const extracted = await extractPublicKeyFromAttestation({ + clientDataJSON: "", + attestationObject: "", + publicKey: base64url.encode(spki), + }); + + expect(Buffer.from(extracted)).toEqual(raw); + expect(extracted).toHaveLength(65); + expect(extracted[0]).toBe(0x04); + }); +}); + +describe("compactSignature", () => { + function toBuf32(x: bigint): Buffer { + return Buffer.from(x.toString(16).padStart(64, "0"), "hex"); + } + + /** DER-encode an (r, s) pair, adding the leading 0x00 pad when needed. */ + function der(r: Buffer, s: Buffer): Buffer { + const pad = (b: Buffer) => + b[0]! & 0x80 ? Buffer.concat([Buffer.from([0x00]), b]) : b; + const rp = pad(r); + const sp = pad(s); + const content = Buffer.concat([ + Buffer.from([0x02, rp.length]), + rp, + Buffer.from([0x02, sp.length]), + sp, + ]); + return Buffer.concat([Buffer.from([0x30, content.length]), content]); + } + + it("returns a 64-byte r||s signature", () => { + const sig = compactSignature(der(Buffer.from([0x01]), Buffer.from([0x02]))); + expect(sig).toHaveLength(64); + expect(Buffer.from(sig.subarray(0, 32)).toString("hex")).toBe( + "00".repeat(31) + "01" + ); + expect(Buffer.from(sig.subarray(32)).toString("hex")).toBe( + "00".repeat(31) + "02" + ); + }); + + it("normalizes a high-S value to low-S", () => { + // s = n - 1 is > n/2; low-S form is n - (n - 1) = 1. + const highS = toBuf32(SECP256R1_CURVE_ORDER - 1n); + const sig = compactSignature(der(Buffer.from([0x01]), highS)); + expect(Buffer.from(sig.subarray(32)).toString("hex")).toBe( + "00".repeat(31) + "01" + ); + }); +}); + +describe("generateChallenge", () => { + it("returns 32 random bytes, base64url-encoded", () => { + const a = generateChallenge(); + const b = generateChallenge(); + expect(base64url.toBuffer(a)).toHaveLength(32); + expect(a).not.toBe(b); + }); +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..0109b54 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,216 @@ +/** + * Cryptographic and derivation helpers for the Passkey Kit SDK. + * + * @packageDocumentation + */ + +import { StrKey, hash, xdr, Address } from "@stellar/stellar-sdk"; +import type { RegistrationResponseJSON } from "@simplewebauthn/browser"; +import base64url from "./base64url.js"; +import { + SECP256R1_PUBLIC_KEY_SIZE, + SECP256R1_CURVE_ORDER, + UNCOMPRESSED_PUBKEY_PREFIX, + WEBAUTHN_CHALLENGE_SIZE, +} from "./constants.js"; +import { WebAuthnError, PasskeyKitErrorCode } from "./errors.js"; + +// ============================================================================ +// Deterministic contract-address derivation +// ============================================================================ + +/** + * Derive a smart-wallet contract address from a passkey credential id. + * + * The wallet's contract id is deterministic: `salt = sha256(keyId)` and the + * deployer address salts a `ContractIdPreimageFromAddress`. This determinism is + * load-bearing — the indexer reverse-lookup (keyId → wallet) re-derives the + * address the same way — so this function is pinned with golden vectors in + * `utils.test.ts`. Any change to the derivation breaks the tests instead of + * silently shifting deployed addresses. + * + * @param keyId - The raw credential id bytes + * @param deployerPublicKey - The deployer's `G…` public key + * @param networkPassphrase - The network passphrase + * @returns The derived contract address (`C…`) + */ +export function deriveContractAddress( + keyId: Buffer, + deployerPublicKey: string, + networkPassphrase: string +): string { + const preimage = xdr.HashIdPreimage.envelopeTypeContractId( + new xdr.HashIdPreimageContractId({ + networkId: hash(Buffer.from(networkPassphrase)), + contractIdPreimage: xdr.ContractIdPreimage.contractIdPreimageFromAddress( + new xdr.ContractIdPreimageFromAddress({ + address: Address.fromString(deployerPublicKey).toScAddress(), + salt: hash(keyId), + }) + ), + }) + ); + + return StrKey.encodeContract(hash(preimage.toXDR())); +} + +// ============================================================================ +// WebAuthn public-key extraction +// ============================================================================ + +/** + * Extract the 65-byte uncompressed secp256r1 public key from a WebAuthn + * registration (attestation) response. + * + * Tries, in order: + * 1. `response.publicKey` when it is already a raw uncompressed EC point. + * 2. `response.publicKey` decoded from SPKI via WebCrypto (`crypto.subtle`), + * which is the correct, non-magic-offset way to normalize the DER-encoded + * SubjectPublicKeyInfo most authenticators return. + * 3. Parsing the COSE key out of `authenticatorData` / `attestationObject`. + * + * @throws {WebAuthnError} If the public key cannot be extracted. + */ +export async function extractPublicKeyFromAttestation( + response: RegistrationResponseJSON["response"] +): Promise { + let publicKey: Buffer | undefined; + + if (response.publicKey) { + const encodedPublicKey = base64url.toBuffer(response.publicKey); + + if ( + encodedPublicKey.length === SECP256R1_PUBLIC_KEY_SIZE && + encodedPublicKey[0] === UNCOMPRESSED_PUBKEY_PREFIX + ) { + publicKey = encodedPublicKey; + } else if (typeof crypto?.subtle !== "undefined") { + try { + const imported = await crypto.subtle.importKey( + "spki", + new Uint8Array(encodedPublicKey), + { name: "ECDSA", namedCurve: "P-256" }, + true, + [] + ); + const rawKey = await crypto.subtle.exportKey("raw", imported); + publicKey = Buffer.from(new Uint8Array(rawKey)); + } catch { + publicKey = encodedPublicKey.subarray( + encodedPublicKey.length - SECP256R1_PUBLIC_KEY_SIZE + ); + } + } else { + publicKey = encodedPublicKey.subarray( + encodedPublicKey.length - SECP256R1_PUBLIC_KEY_SIZE + ); + } + } + + // Validate it is a proper uncompressed EC point; otherwise fall back to the + // COSE key embedded in authenticatorData / attestationObject. + if ( + !publicKey || + publicKey[0] !== UNCOMPRESSED_PUBKEY_PREFIX || + publicKey.length !== SECP256R1_PUBLIC_KEY_SIZE + ) { + let x: Buffer; + let y: Buffer; + + if (response.authenticatorData) { + const authenticatorData = base64url.toBuffer(response.authenticatorData); + const credentialIdLength = + (authenticatorData[53]! << 8) | authenticatorData[54]!; + + x = authenticatorData.subarray( + 65 + credentialIdLength, + 97 + credentialIdLength + ); + y = authenticatorData.subarray( + 100 + credentialIdLength, + 132 + credentialIdLength + ); + } else if (response.attestationObject) { + const attestationObject = base64url.toBuffer(response.attestationObject); + + // COSE key structure prefix for an ES256 (P-256) key. + const publicKeyPrefixSlice = Buffer.from([ + 0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20, + ]); + let startIndex = attestationObject.indexOf(publicKeyPrefixSlice); + if (startIndex === -1) { + throw new WebAuthnError( + "Could not locate the COSE public key in the attestation object", + PasskeyKitErrorCode.PUBLIC_KEY_EXTRACTION_FAILED + ); + } + startIndex = startIndex + publicKeyPrefixSlice.length; + + x = attestationObject.subarray(startIndex, 32 + startIndex); + y = attestationObject.subarray(35 + startIndex, 67 + startIndex); + } else { + throw new WebAuthnError( + "Could not extract a public key from the attestation response", + PasskeyKitErrorCode.PUBLIC_KEY_EXTRACTION_FAILED + ); + } + + publicKey = Buffer.from([UNCOMPRESSED_PUBKEY_PREFIX, ...x, ...y]); + } + + return new Uint8Array(publicKey); +} + +// ============================================================================ +// Signature encoding +// ============================================================================ + +/** + * Convert a DER-encoded ECDSA signature to Stellar's compact `r || s` form with + * a low-S value (S <= n/2), as required by the secp256r1 host function. + * + * @param derSignature - The DER-encoded signature + * @returns 64-byte compact signature (`r || s`) + */ +export function compactSignature(derSignature: Buffer): Uint8Array { + // DER: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s] + let offset = 2; // skip 0x30 and the total length + + const rLength = derSignature[offset + 1]!; + const r = derSignature.subarray(offset + 2, offset + 2 + rLength); + + offset += 2 + rLength; + + const sLength = derSignature[offset + 1]!; + const s = derSignature.subarray(offset + 2, offset + 2 + sLength); + + const rBigInt = BigInt("0x" + r.toString("hex")); + let sBigInt = BigInt("0x" + s.toString("hex")); + + // Enforce low-S. + const halfN = SECP256R1_CURVE_ORDER / 2n; + if (sBigInt > halfN) { + sBigInt = SECP256R1_CURVE_ORDER - sBigInt; + } + + const rPadded = Buffer.from(rBigInt.toString(16).padStart(64, "0"), "hex"); + const sLowS = Buffer.from(sBigInt.toString(16).padStart(64, "0"), "hex"); + + return new Uint8Array(Buffer.concat([rPadded, sLowS])); +} + +// ============================================================================ +// WebAuthn challenge +// ============================================================================ + +/** + * Generate a cryptographically-random base64url challenge for a WebAuthn + * ceremony. + * + * Replaces the old hard-coded `"stellaristhebetterblockchain"` challenge. + */ +export function generateChallenge(): string { + const bytes = new Uint8Array(WEBAUTHN_CHALLENGE_SIZE); + crypto.getRandomValues(bytes); + return base64url.encode(Buffer.from(bytes)); +} diff --git a/src/validation.test.ts b/src/validation.test.ts new file mode 100644 index 0000000..993e95d --- /dev/null +++ b/src/validation.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + validateAddress, + validateAmount, + validateExpiration, + validateSecp256r1PublicKey, +} from "./validation.js"; +import { PasskeyKitErrorCode, ValidationError } from "./errors.js"; + +const G_ADDRESS = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 1)).publicKey(); +const C_ADDRESS = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL"; + +describe("validateAddress", () => { + it("accepts G… and C… addresses", () => { + expect(() => validateAddress(G_ADDRESS)).not.toThrow(); + expect(() => validateAddress(C_ADDRESS)).not.toThrow(); + }); + + it("rejects malformed or empty addresses with INVALID_ADDRESS", () => { + expect(() => validateAddress("not-an-address")).toThrow(ValidationError); + try { + validateAddress(""); + } catch (err) { + expect((err as ValidationError).code).toBe( + PasskeyKitErrorCode.INVALID_ADDRESS + ); + } + }); +}); + +describe("validateAmount", () => { + it("accepts positive finite numbers", () => { + expect(() => validateAmount(1)).not.toThrow(); + expect(() => validateAmount(0.5)).not.toThrow(); + }); + + it("rejects zero, negatives, and non-finite values", () => { + expect(() => validateAmount(0)).toThrow(ValidationError); + expect(() => validateAmount(-1)).toThrow(ValidationError); + expect(() => validateAmount(Number.NaN)).toThrow(ValidationError); + expect(() => validateAmount(Number.POSITIVE_INFINITY)).toThrow( + ValidationError + ); + }); +}); + +describe("validateExpiration", () => { + it("accepts undefined and valid u32 values", () => { + expect(() => validateExpiration(undefined)).not.toThrow(); + expect(() => validateExpiration(0)).not.toThrow(); + expect(() => validateExpiration(0xffffffff)).not.toThrow(); + }); + + it("rejects negatives, non-integers, and out-of-range values", () => { + expect(() => validateExpiration(-1)).toThrow(ValidationError); + expect(() => validateExpiration(1.5)).toThrow(ValidationError); + expect(() => validateExpiration(0x100000000)).toThrow(ValidationError); + }); +}); + +describe("validateSecp256r1PublicKey", () => { + it("accepts a 65-byte uncompressed point", () => { + const key = new Uint8Array(65); + key[0] = 0x04; + expect(() => validateSecp256r1PublicKey(key)).not.toThrow(); + }); + + it("rejects wrong length or wrong prefix with INVALID_PUBLIC_KEY", () => { + const wrongLength = new Uint8Array(64); + wrongLength[0] = 0x04; + const wrongPrefix = new Uint8Array(65); + wrongPrefix[0] = 0x02; + + for (const bad of [wrongLength, wrongPrefix]) { + try { + validateSecp256r1PublicKey(bad); + throw new Error("expected to throw"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).code).toBe( + PasskeyKitErrorCode.INVALID_PUBLIC_KEY + ); + } + } + }); +}); diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..100038b --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,121 @@ +/** + * Client-side input validation. + * + * Produces clear {@link ValidationError}s before submission instead of opaque + * on-chain or WebCrypto failures. The smart wallet imposes no signer-count caps + * (unlike a policy-based account), so validation here is about shapes and + * ranges: addresses, amounts, expirations, and secp256r1 public keys. + * + * @packageDocumentation + */ + +import { StrKey } from "@stellar/stellar-sdk"; +import { + SECP256R1_PUBLIC_KEY_SIZE, + UNCOMPRESSED_PUBKEY_PREFIX, +} from "./constants.js"; +import { PasskeyKitErrorCode, ValidationError } from "./errors.js"; + +/** + * Validate that a string is a Stellar account (`G…`) or contract (`C…`) address. + * + * @throws {ValidationError} If the address is missing or malformed. + */ +export function validateAddress( + address: string, + fieldName = "address" +): void { + if (!address || typeof address !== "string") { + throw new ValidationError( + `${fieldName} is required`, + PasskeyKitErrorCode.INVALID_ADDRESS, + { field: fieldName } + ); + } + + if ( + !StrKey.isValidEd25519PublicKey(address) && + !StrKey.isValidContract(address) + ) { + throw new ValidationError( + `Invalid ${fieldName}: must be a valid Stellar account (G…) or contract (C…) address`, + PasskeyKitErrorCode.INVALID_ADDRESS, + { field: fieldName, value: address.slice(0, 10) + "…" } + ); + } +} + +/** + * Validate that an amount is a positive, finite number. + * + * @throws {ValidationError} If the amount is not a positive number. + */ +export function validateAmount(amount: number, fieldName = "amount"): void { + if (typeof amount !== "number" || !Number.isFinite(amount)) { + throw new ValidationError( + `${fieldName} must be a number`, + PasskeyKitErrorCode.INVALID_AMOUNT, + { field: fieldName } + ); + } + if (amount <= 0) { + throw new ValidationError( + `${fieldName} must be positive`, + PasskeyKitErrorCode.INVALID_AMOUNT, + { field: fieldName, value: amount } + ); + } +} + +/** + * Validate a signer expiration. + * + * Must be a non-negative integer within `u32` bounds when provided; `undefined` + * means "no expiration". (Unix-second timestamps fit in `u32` through the year + * 2106, so this bound holds whether the contract interprets the value as a + * ledger sequence or a UNIX timestamp.) + * + * @throws {ValidationError} If the expiration is out of range. + */ +export function validateExpiration( + expiration: number | undefined, + fieldName = "expiration" +): void { + if (expiration === undefined) return; + if ( + !Number.isInteger(expiration) || + expiration < 0 || + expiration > 0xffffffff + ) { + throw new ValidationError( + `${fieldName} must be a non-negative integer within u32 bounds`, + PasskeyKitErrorCode.INVALID_INPUT, + { field: fieldName, value: expiration } + ); + } +} + +/** + * Validate a secp256r1 public key: 65 bytes, uncompressed (`0x04`) prefix. + * + * @throws {ValidationError} If the key is not a valid uncompressed EC point. + */ +export function validateSecp256r1PublicKey( + publicKey: Uint8Array, + fieldName = "publicKey" +): void { + if (publicKey.length !== SECP256R1_PUBLIC_KEY_SIZE) { + throw new ValidationError( + `${fieldName} must be ${SECP256R1_PUBLIC_KEY_SIZE} bytes (got ${publicKey.length})`, + PasskeyKitErrorCode.INVALID_PUBLIC_KEY, + { field: fieldName, length: publicKey.length } + ); + } + if (publicKey[0] !== UNCOMPRESSED_PUBKEY_PREFIX) { + throw new ValidationError( + `${fieldName} must be an uncompressed EC point (0x04 prefix)`, + PasskeyKitErrorCode.INVALID_PUBLIC_KEY, + { field: fieldName, prefix: publicKey[0] } + ); + } +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..c3be401 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,10 @@ +/** + * Package version — auto-generated from package.json. + * @packageDocumentation + */ + +// This file is auto-generated. Do not edit manually. +// Run `pnpm run sync-version` (or `pnpm build`) to update. + +export const VERSION = "0.12.1"; +export const NAME = "passkey-kit"; diff --git a/tsconfig.json b/tsconfig.json index 5c30dc8..9056806 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,28 +1,23 @@ { "compilerOptions": { - "outDir": "./types", - "rootDir": "./src", - // Enable latest features - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - // Bundler mode - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], "declaration": true, - "emitDeclarationOnly": true, - // Best practices + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", "strict": true, + "esModuleInterop": true, "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false, - "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": false, + "noFallthroughCasesInSwitch": true }, - "include": [ - "src", - ], -} \ No newline at end of file + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} From 067f8079a4276ce424aa8dd6012f9fdbf9fc4110 Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 21:18:27 -0400 Subject: [PATCH 06/30] feat(contracts)!: ground-up rework to v1 locked interface spec (soroban-sdk 27) - single types source: interface crate exports spec types/events/errors; duplicated smart-wallet/src/types.rs deleted - timestamp-based SignerExpiration (u64 unix seconds, inclusive) - fail-closed SignerLimits (Some(empty) = no permissions); deploy requires unlimited signer; limited signers may always self-remove (typed, boolean) - __check_auth: pass-1 context coverage purely boolean; pass-2 single-point expiration + crypto/policy verification of every signatures entry - WebAuthn: type==webauthn.get + UP flag + authData length checks, typed oversized-clientDataJSON error; challenge equality unchanged - policy install/uninstall lifecycle (hard install, best-effort uninstall); sample-policy demonstrates installed-wallet gating; recursion depth guard - upgrade(new_wasm_hash) + Upgraded event + contractmeta binver; constructor- only init (init flag deleted); get_signer view; #[contractevent] schema (signer_added/signer_updated/signer_removed/upgraded) - errors renumbered to 100+ ranges (legacy 1-9 disjoint for SDK detection) - Makefile: fix wrong-dir wipe/double build, add test target, CLI 27 optimize Co-Authored-By: Claude Fable 5 --- contracts/Cargo.lock | 368 ++++++++++++------ contracts/Cargo.toml | 9 +- contracts/Makefile | 36 +- contracts/example-contract/Cargo.toml | 12 +- contracts/example-contract/src/lib.rs | 21 +- contracts/rust-toolchain.toml | 4 +- contracts/sample-policy/Cargo.toml | 12 +- contracts/sample-policy/src/lib.rs | 68 +++- contracts/smart-wallet-interface/Cargo.toml | 14 +- .../smart-wallet-interface/src/events.rs | 58 +++ contracts/smart-wallet-interface/src/lib.rs | 44 ++- contracts/smart-wallet-interface/src/types.rs | 150 +++++-- contracts/smart-wallet/Cargo.toml | 12 +- contracts/smart-wallet/src/context.rs | 197 +++++++--- contracts/smart-wallet/src/lib.rs | 303 ++++++++------ contracts/smart-wallet/src/signer.rs | 161 +++----- contracts/smart-wallet/src/storage.rs | 11 + contracts/smart-wallet/src/tests/test.rs | 4 +- .../smart-wallet/src/tests/test_extra.rs | 35 +- contracts/smart-wallet/src/types.rs | 85 ---- contracts/smart-wallet/src/verify.rs | 86 +++- 21 files changed, 1039 insertions(+), 651 deletions(-) create mode 100644 contracts/smart-wallet-interface/src/events.rs delete mode 100644 contracts/smart-wallet/src/types.rs diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index b51465a..6a60c92 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -29,6 +29,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -55,9 +61,9 @@ dependencies = [ [[package]] name = "ark-bls12-381" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" dependencies = [ "ark-ec", "ark-ff", @@ -65,112 +71,136 @@ dependencies = [ "ark-std", ] +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + [[package]] name = "ark-ec" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" dependencies = [ + "ahash", "ark-ff", "ark-poly", "ark-serialize", "ark-std", - "derivative", - "hashbrown 0.13.2", + "educe", + "fnv", + "hashbrown 0.15.5", "itertools", + "num-bigint", + "num-integer", "num-traits", "zeroize", ] [[package]] name = "ark-ff" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" dependencies = [ "ark-ff-asm", "ark-ff-macros", "ark-serialize", "ark-std", - "derivative", + "arrayvec", "digest", + "educe", "itertools", "num-bigint", "num-traits", "paste", - "rustc_version", "zeroize", ] [[package]] name = "ark-ff-asm" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 1.0.109", + "syn", ] [[package]] name = "ark-ff-macros" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" dependencies = [ "num-bigint", "num-traits", "proc-macro2", "quote", - "syn 1.0.109", + "syn", ] [[package]] name = "ark-poly" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" dependencies = [ + "ahash", "ark-ff", "ark-serialize", "ark-std", - "derivative", - "hashbrown 0.13.2", + "educe", + "fnv", + "hashbrown 0.15.5", ] [[package]] name = "ark-serialize" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ "ark-serialize-derive", "ark-std", + "arrayvec", "digest", "num-bigint", ] [[package]] name = "ark-serialize-derive" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn", ] [[package]] name = "ark-std" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", "rand", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-trait" version = "0.1.89" @@ -179,7 +209,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -242,6 +272,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -250,14 +286,14 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "bytes-lit" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0adabf37211a5276e46335feabcbb1530c95eb3fdf85f324c7db942770aa025d" +checksum = "9b04f2b1d34cb428043f14aa4c853d14294532e8bbde3b6a3bc2faaaae31a1dd" dependencies = [ "num-bigint", "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -283,7 +319,7 @@ checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -331,6 +367,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "crate-git-revision" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54851b5b3f24621804b1cded2820975623c205e3055d2d44031cdb1237339ac8" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -355,14 +402,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" dependencies = [ - "quote", - "syn 2.0.106", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -387,7 +440,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -411,7 +464,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.106", + "syn", ] [[package]] @@ -422,7 +475,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -451,17 +504,6 @@ dependencies = [ "serde", ] -[[package]] -name = "derivative" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "derive_arbitrary" version = "1.3.2" @@ -470,7 +512,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -491,6 +533,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -535,6 +592,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.15.0" @@ -559,6 +628,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -573,13 +662,13 @@ checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "example-contract" -version = "0.0.0" +version = "1.0.0" dependencies = [ "smart-wallet", "soroban-sdk", @@ -648,6 +737,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -656,18 +754,22 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.13.2" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "ahash", + "allocator-api2", ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "heapless" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] [[package]] name = "heck" @@ -770,9 +872,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.10.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -840,7 +942,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -893,7 +995,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -985,7 +1087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.106", + "syn", ] [[package]] @@ -1062,7 +1164,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -1104,7 +1206,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "sample-policy" -version = "0.0.0" +version = "1.0.0" dependencies = [ "smart-wallet-interface", "soroban-sdk", @@ -1191,7 +1293,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -1236,7 +1338,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -1303,7 +1405,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smart-wallet" -version = "0.5.0" +version = "1.0.0" dependencies = [ "base64", "ed25519-dalek", @@ -1314,36 +1416,36 @@ dependencies = [ "sha256", "smart-wallet-interface", "soroban-sdk", - "stellar-strkey", + "stellar-strkey 0.0.13", ] [[package]] name = "smart-wallet-interface" -version = "0.5.0" +version = "1.0.0" dependencies = [ "soroban-sdk", ] [[package]] name = "soroban-builtin-sdk-macros" -version = "23.0.1" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9336adeabcd6f636a4e0889c8baf494658ef5a3c4e7e227569acd2ce9091e85" +checksum = "d769030e3b2c27873e9be4931decbbe79787b943d5b30e31f8c395eae83144b8" dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] name = "soroban-env-common" -version = "23.0.1" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00067f52e8bbf1abf0de03fe3e2fbb06910893cfbe9a7d9093d6425658833ff3" +checksum = "5aa50d998c0baafcc6078cb94f5228040c7719b1608c15debc3fc78365dc22f5" dependencies = [ "arbitrary", - "crate-git-revision", + "crate-git-revision 0.0.6", "ethnum", "num-derive", "num-traits", @@ -1357,9 +1459,9 @@ dependencies = [ [[package]] name = "soroban-env-guest" -version = "23.0.1" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd1e40963517b10963a8e404348d3fe6caf9c278ac47a6effd48771297374d6" +checksum = "fde1064f5e92dbcc8018ecc8e92728bf5bbff0236abaf792c6858367a6853415" dependencies = [ "soroban-env-common", "static_assertions", @@ -1367,11 +1469,12 @@ dependencies = [ [[package]] name = "soroban-env-host" -version = "23.0.1" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9766c5ad78e9d8ae10afbc076301f7d610c16407a1ebb230766dbe007a48725" +checksum = "f47762a1b75b9ccd07558f28e1a0289ca6adec56810c581cde5cf16e329e00b9" dependencies = [ "ark-bls12-381", + "ark-bn254", "ark-ec", "ark-ff", "ark-serialize", @@ -1397,15 +1500,15 @@ dependencies = [ "soroban-env-common", "soroban-wasmi", "static_assertions", - "stellar-strkey", + "stellar-strkey 0.0.13", "wasmparser", ] [[package]] name = "soroban-env-macros" -version = "23.0.1" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e6a1c5844257ce96f5f54ef976035d5bd0ee6edefaf9f5e0bcb8ea4b34228c" +checksum = "bc04b813a9e32456b37875fc41cdb05912a9e947000b9414b7985bffe07a889a" dependencies = [ "itertools", "proc-macro2", @@ -1413,14 +1516,14 @@ dependencies = [ "serde", "serde_json", "stellar-xdr", - "syn 2.0.106", + "syn", ] [[package]] name = "soroban-ledger-snapshot" -version = "23.0.2" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3823372b72cab2e7ff2ced62bbffa11fce8da0713a224f122141558cab174647" +checksum = "1e4bf41f85da648dca4153ca5ed41411658d2029e15d9ba693fdd2e3e5dbe05d" dependencies = [ "serde", "serde_json", @@ -1432,13 +1535,13 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "23.0.2" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af0e5bf6702f5952d78c5b2bcd05b0349f9a570cc62028d90dac3710b40cbe65" +checksum = "a5636f9d62dd0cc6d23f08f775aa60f712ec596e5761f6911aee154337d401bf" dependencies = [ "arbitrary", "bytes-lit", - "crate-git-revision", + "crate-git-revision 0.0.9", "ctor", "derive_arbitrary", "ed25519-dalek", @@ -1450,14 +1553,15 @@ dependencies = [ "soroban-env-host", "soroban-ledger-snapshot", "soroban-sdk-macros", - "stellar-strkey", + "stellar-strkey 0.0.16", + "visibility", ] [[package]] name = "soroban-sdk-macros" -version = "23.0.2" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b38abe20199c5d9fbff232381aa4e8e83302b34e82e38fbb090f41f1284fc920" +checksum = "f7c9f001d91196d9cd659634f9113f75609819204aef5fae27b4ab845cdfc741" dependencies = [ "darling", "heck", @@ -1470,16 +1574,17 @@ dependencies = [ "soroban-spec", "soroban-spec-rust", "stellar-xdr", - "syn 2.0.106", + "syn", ] [[package]] name = "soroban-spec" -version = "23.0.2" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72526d30f8825b859afa7e0b94549dad05c58a6c928b0763620412744512d7e2" +checksum = "ca1e22ce713871c6f9d21a321051b58e53080629b6a5be1132cf11f42cca4d10" dependencies = [ "base64", + "sha2", "stellar-xdr", "thiserror", "wasmparser", @@ -1487,9 +1592,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "23.0.2" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9088cb8307dad026cda494971c4f13c76f9427ab26becb7cd691da95dc5e9b1d" +checksum = "89fb158f79ec527e46a5d5162f11c4269e17748e6b69391cdf648133d76cfead" dependencies = [ "prettyplease", "proc-macro2", @@ -1497,7 +1602,7 @@ dependencies = [ "sha2", "soroban-spec", "stellar-xdr", - "syn 2.0.106", + "syn", "thiserror", ] @@ -1530,6 +1635,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1542,27 +1653,38 @@ version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913" dependencies = [ - "crate-git-revision", + "crate-git-revision 0.0.6", "data-encoding", ] +[[package]] +name = "stellar-strkey" +version = "0.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520" +dependencies = [ + "crate-git-revision 0.0.6", + "data-encoding", + "heapless", +] + [[package]] name = "stellar-xdr" -version = "23.0.0" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d2848e1694b0c8db81fd812bfab5ea71ee28073e09ccc45620ef3cf7a75a9b" +checksum = "05ff843326969bdf1ef673dcdba94c08f4a3c8f1e58d6e6ef39b1bd4f749179a" dependencies = [ "arbitrary", "base64", "cfg_eval", - "crate-git-revision", + "crate-git-revision 0.0.6", "escape-bytes", "ethnum", "hex", "serde", "serde_with", "sha2", - "stellar-strkey", + "stellar-strkey 0.0.13", ] [[package]] @@ -1577,17 +1699,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.106" @@ -1616,7 +1727,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -1683,6 +1794,17 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1711,7 +1833,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.106", + "syn", "wasm-bindgen-shared", ] @@ -1733,7 +1855,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1805,7 +1927,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -1816,7 +1938,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -1933,7 +2055,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -1953,5 +2075,5 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 9574a47..e2fffba 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -3,8 +3,15 @@ resolver = "2" members = ["smart-wallet", "smart-wallet-interface", "example-contract", "sample-policy"] +[workspace.package] +version = "1.0.0" +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +edition = "2021" +publish = false + [workspace.dependencies] -soroban-sdk = "23.0" +soroban-sdk = "27.0.0" serde = { version = "1.0", default-features = false } serde-json-core = { version = "0.6", default-features = false } sha256 = { version = "1.6" } diff --git a/contracts/Makefile b/contracts/Makefile index 9c64da8..f9c0cef 100644 --- a/contracts/Makefile +++ b/contracts/Makefile @@ -1,31 +1,33 @@ -export STELLAR_NETWORK=testnet -export STELLAR_ACCOUNT=default -# export STELLAR_NETWORK=vc -# export STELLAR_ACCOUNT=live +export STELLAR_NETWORK ?= testnet +export STELLAR_ACCOUNT ?= default -# TESTNET -export SMART_WALLET_WASM=e45c42b944a767bd5f37f8c4a469b48917d28e23481dbfd550419c84cdacde92 +# Native XLM SAC on testnet, used only for sac-sdk bindings generation. +SAC_ID ?= CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# Canonical wasm hashes live in docs/deployments-testnet.md — this Makefile +# does not pin them; `make build` output is the source of truth. + +# `stellar contract build` optimizes by default (CLI 27+); out/smart_wallet.wasm +# is the canonical, already-optimized artifact. build: rm -rf out/ - rm -rf smart-wallet/target/wasm32v1-none/ + stellar contract build --package smart-wallet --out-dir out - stellar contract build - - @cd smart-wallet && stellar contract build --package smart-wallet --out-dir ../out - stellar contract optimize --wasm ./out/smart_wallet.wasm +# Tests import sample_policy.wasm (cross-contract fixture), so build it first. +test: + stellar contract build --package sample-policy + cargo test upload: - stellar contract upload --wasm ./out/smart_wallet.optimized.wasm --source $(STELLAR_ACCOUNT) + stellar contract upload --wasm out/smart_wallet.wasm --source $(STELLAR_ACCOUNT) --network $(STELLAR_NETWORK) bindings: - stellar contract bindings typescript --id CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC --network $(STELLAR_NETWORK) --output-dir ./out/sac-sdk --overwrite - stellar contract bindings typescript --wasm ./target/wasm32v1-none/release/smart_wallet.wasm --network $(STELLAR_NETWORK) --output-dir ./out/passkey-kit-sdk --overwrite + stellar contract bindings typescript --id $(SAC_ID) --network $(STELLAR_NETWORK) --output-dir out/sac-sdk --overwrite + stellar contract bindings typescript --wasm out/smart_wallet.wasm --network $(STELLAR_NETWORK) --output-dir out/passkey-kit-sdk --overwrite fmt: - @cd smart-wallet && cargo fmt --all + cargo fmt --all clean: rm -rf out/ - rm -rf smart-wallet/target/ - @cd smart-wallet && cargo clean \ No newline at end of file + cargo clean diff --git a/contracts/example-contract/Cargo.toml b/contracts/example-contract/Cargo.toml index d0209b0..9245eed 100644 --- a/contracts/example-contract/Cargo.toml +++ b/contracts/example-contract/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "example-contract" -version = "0.0.0" -authors = ["Stellar Development Foundation "] -license = "Apache-2.0" -edition = "2021" -publish = false +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +publish.workspace = true [lib] crate-type = ["cdylib", "rlib"] @@ -18,4 +18,4 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } -smart-wallet = { workspace = true, features = ["testutils"] } \ No newline at end of file +smart-wallet = { workspace = true, features = ["testutils"] } diff --git a/contracts/example-contract/src/lib.rs b/contracts/example-contract/src/lib.rs index 4f60b45..e128d3e 100644 --- a/contracts/example-contract/src/lib.rs +++ b/contracts/example-contract/src/lib.rs @@ -1,3 +1,9 @@ +//! Test fixture used by the smart wallet test suite. Not deployed. +//! +//! `deploy` exercises the wallet authorizing a `CreateContract*` context; +//! `call` exercises a root invocation fanning out into multiple `transfer` +//! sub-invocations under a single wallet authorization. + #![no_std] use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env}; @@ -12,19 +18,10 @@ impl Contract { .with_address(source, wasm_hash.clone()) .deploy_v2(wasm_hash, ()); } - pub fn call( - env: Env, - sac: Address, - from: Address, - to: Address, - amount: i128, - // signer_key: SignerKey, - // signer: Signer, - ) { + + pub fn call(env: Env, sac: Address, from: Address, to: Address, amount: i128) { from.require_auth(); token::Client::new(&env, &sac).transfer(&from, &to, &amount); - token::Client::new(&env, &sac).transfer(&from, &to, &10_000_00); - // smart_wallet::ContractClient::new(&env, &from).remove(&signer_key); - // smart_wallet::ContractClient::new(&env, &from).add(&signer); + token::Client::new(&env, &sac).transfer(&from, &to, &1_000_000); } } diff --git a/contracts/rust-toolchain.toml b/contracts/rust-toolchain.toml index 29a0c9a..6c79035 100644 --- a/contracts/rust-toolchain.toml +++ b/contracts/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.89" +channel = "1.94.0" targets = ["wasm32v1-none"] -components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"] \ No newline at end of file +components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"] diff --git a/contracts/sample-policy/Cargo.toml b/contracts/sample-policy/Cargo.toml index 5271066..be2d2c4 100644 --- a/contracts/sample-policy/Cargo.toml +++ b/contracts/sample-policy/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "sample-policy" -version = "0.0.0" -authors = ["Stellar Development Foundation "] -license = "Apache-2.0" -edition = "2021" -publish = false +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +publish.workspace = true [lib] crate-type = ["cdylib", "rlib"] @@ -18,4 +18,4 @@ soroban-sdk = { workspace = true } smart-wallet-interface = { workspace = true } [dev-dependencies] -soroban-sdk = { workspace = true, features = ["testutils"] } \ No newline at end of file +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/sample-policy/src/lib.rs b/contracts/sample-policy/src/lib.rs index 51600c0..4b43a16 100644 --- a/contracts/sample-policy/src/lib.rs +++ b/contracts/sample-policy/src/lib.rs @@ -1,17 +1,39 @@ +//! Sample policy demonstrating the smart wallet policy lifecycle. +//! +//! `policy__` is publicly callable — anyone can invoke it with arbitrary +//! arguments — so a policy holding any per-wallet state MUST gate on wallets +//! that actually installed it. The wallet invokes `install`/`uninstall` as +//! the direct cross-contract caller, so `wallet.require_auth()` inside the +//! hooks passes via invoker auth for legitimate calls and requires a real +//! wallet authorization for anyone else. +//! +//! This sample rejects `transfer` invocations over 10_000_000 stroops for +//! installed wallets, and rejects everything for wallets that never +//! installed it. + #![no_std] use smart_wallet_interface::{types::SignerKey, PolicyInterface}; use soroban_sdk::{ auth::{Context, ContractContext}, - contract, contracterror, contractimpl, panic_with_error, symbol_short, Address, Env, - TryFromVal, Vec, + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, + Env, TryFromVal, Vec, }; #[contracterror] -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] -pub enum Error { +pub enum PolicyError { NotAllowed = 1, + NotInstalled = 2, +} + +const TRANSFER_LIMIT: i128 = 10_000_000; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StorageKey { + Installed(Address), } #[contract] @@ -19,19 +41,45 @@ pub struct Contract; #[contractimpl] impl PolicyInterface for Contract { - fn policy__(env: Env, _source: Address, _signer: SignerKey, contexts: Vec) { + fn install(env: Env, wallet: Address) { + wallet.require_auth(); + + env.storage() + .persistent() + .set::(&StorageKey::Installed(wallet), &true); + } + + fn uninstall(env: Env, wallet: Address) { + wallet.require_auth(); + + env.storage() + .persistent() + .remove::(&StorageKey::Installed(wallet)); + } + + fn policy__(env: Env, source: Address, _signer: SignerKey, contexts: Vec) { + if !env + .storage() + .persistent() + .has::(&StorageKey::Installed(source)) + { + panic_with_error!(&env, PolicyError::NotInstalled) + } + for context in contexts.iter() { match context { Context::Contract(ContractContext { fn_name, args, .. }) => { - if let Some(amount_val) = args.get(2) { - if let Ok(amount) = i128::try_from_val(&env, &amount_val) { - if fn_name == symbol_short!("transfer") && amount > 10_000_000 { - panic_with_error!(&env, Error::NotAllowed) + if fn_name == symbol_short!("transfer") { + if let Some(amount_val) = args.get(2) { + if let Ok(amount) = i128::try_from_val(&env, &amount_val) { + if amount > TRANSFER_LIMIT { + panic_with_error!(&env, PolicyError::NotAllowed) + } } } } } - _ => panic_with_error!(&env, Error::NotAllowed), + _ => panic_with_error!(&env, PolicyError::NotAllowed), } } } diff --git a/contracts/smart-wallet-interface/Cargo.toml b/contracts/smart-wallet-interface/Cargo.toml index c3b086a..fd8a9fb 100644 --- a/contracts/smart-wallet-interface/Cargo.toml +++ b/contracts/smart-wallet-interface/Cargo.toml @@ -1,17 +1,17 @@ [package] name = "smart-wallet-interface" -version = "0.5.0" -authors = ["Stellar Development Foundation "] -license = "Apache-2.0" -edition = "2021" -publish = false +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +publish.workspace = true [lib] -crate-type = ["cdylib", "rlib"] +crate-type = ["rlib"] doctest = false [features] testutils = [] [dependencies] -soroban-sdk = { workspace = true } \ No newline at end of file +soroban-sdk = { workspace = true } diff --git a/contracts/smart-wallet-interface/src/events.rs b/contracts/smart-wallet-interface/src/events.rs new file mode 100644 index 0000000..49ebe64 --- /dev/null +++ b/contracts/smart-wallet-interface/src/events.rs @@ -0,0 +1,58 @@ +//! Smart wallet event schema (v1). +//! +//! These replace the legacy `("sw_v1", , )` tuple events. Each +//! event is a `#[contractevent]` struct, so its full schema is embedded in the +//! contract spec (SEP-48) and consumed by indexers from the wasm itself. The +//! first topic is the snake_case struct name (`signer_added`, `signer_updated`, +//! `signer_removed`, `upgraded`), which is the version marker for this scheme. +//! +//! Events are a complete mirror of signer-storage transitions: the full new +//! state is always emitted, plus the old storage class on updates so indexers +//! can detect durability moves (an update that flips durability tombstones the +//! entry in one durability while a live twin appears in the other). + +use soroban_sdk::{contractevent, BytesN}; + +use crate::types::{SignerKey, SignerStorage, SignerVal}; + +/// A signer was added (via `__constructor` or `add_signer`). +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerAdded { + #[topic] + pub key: SignerKey, + pub val: SignerVal, + pub storage: SignerStorage, +} + +/// An existing signer was modified via `update_signer`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerUpdated { + #[topic] + pub key: SignerKey, + pub val: SignerVal, + pub storage: SignerStorage, + pub old_storage: SignerStorage, +} + +/// A signer was removed via `remove_signer`. `storage` is the durability the +/// entry was removed from. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerRemoved { + #[topic] + pub key: SignerKey, + pub storage: SignerStorage, +} + +/// The contract's wasm was replaced via `upgrade`. `old_hash` is `None` on a +/// wallet's first-ever upgrade: the host exposes no way for a contract to +/// read its own executable hash, so the wallet caches the hash in instance +/// storage at each upgrade and the genesis hash is unknowable in-contract. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Upgraded { + pub old_hash: Option>, + pub new_hash: BytesN<32>, +} diff --git a/contracts/smart-wallet-interface/src/lib.rs b/contracts/smart-wallet-interface/src/lib.rs index 8d4927a..c6ac48f 100644 --- a/contracts/smart-wallet-interface/src/lib.rs +++ b/contracts/smart-wallet-interface/src/lib.rs @@ -1,20 +1,54 @@ #![no_std] use soroban_sdk::{auth::Context, contractclient, Address, BytesN, Env, Vec}; -use types::{Signer, SignerKey}; +use types::{Error, Signer, SignerKey, SignerVal}; +pub mod events; pub mod types; #[contractclient(name = "SmartWalletClient")] pub trait SmartWalletInterface { + /// Initialize the wallet with its first signer. Deploy-time only + /// (CAP-0058); there is no other initialization path and no + /// un-authenticated `add_signer` window. fn __constructor(env: Env, signer: Signer); - fn add_signer(env: Env, signer: Signer); - fn update_signer(env: Env, signer: Signer); - fn remove_signer(env: Env, signer_key: SignerKey); - fn update_contract_code(env: Env, hash: BytesN<32>); + /// Add a new signer. Requires wallet auth. Fails if the signer key + /// already exists. Policy signers get their `install` hook invoked. + fn add_signer(env: Env, signer: Signer) -> Result<(), Error>; + /// Replace an existing signer's value and/or storage durability. + /// Requires wallet auth. Fails if the signer key does not exist. + fn update_signer(env: Env, signer: Signer) -> Result<(), Error>; + /// Remove a signer. Requires wallet auth. Policy signers get their + /// `uninstall` hook invoked (best-effort: a failing or malicious policy + /// cannot block its own removal). + fn remove_signer(env: Env, signer_key: SignerKey) -> Result<(), Error>; + /// Replace the contract's wasm. Requires wallet auth. Emits `Upgraded`. + fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error>; + /// Return the stored signer value for a key, or `None` if not stored. + /// Returns the raw stored value — expiration is NOT filtered; check + /// `SignerExpiration` client-side. + fn get_signer(env: Env, signer_key: SignerKey) -> Option; } #[contractclient(name = "PolicyClient")] pub trait PolicyInterface { + /// Authorization check invoked by a smart wallet during `__check_auth`. + /// `source` is the wallet, `signer` is the signer key the check is being + /// performed for, `contexts` is what is being authorized (the full + /// context list when the policy is used as a signature; a single context + /// when the policy is used inside another signer's limits). Panic to + /// reject. + /// + /// PUBLICLY CALLABLE: anyone can invoke `policy__` with arbitrary + /// arguments. Stateful policies MUST gate on wallets that actually + /// installed them (see `install`) and MUST NOT trust `source` otherwise. fn policy__(env: Env, source: Address, signer: SignerKey, contexts: Vec); + /// Lifecycle hook invoked by a wallet when this policy is added as a + /// signer. Implementations should `wallet.require_auth()` — the wallet is + /// the direct invoker, so invoker auth makes this trustworthy — and set + /// up any per-wallet state. + fn install(env: Env, wallet: Address); + /// Lifecycle hook invoked by a wallet when this policy is removed. The + /// wallet invokes this best-effort: a panic here does not block removal. + fn uninstall(env: Env, wallet: Address); } diff --git a/contracts/smart-wallet-interface/src/types.rs b/contracts/smart-wallet-interface/src/types.rs index c785feb..de435b7 100644 --- a/contracts/smart-wallet-interface/src/types.rs +++ b/contracts/smart-wallet-interface/src/types.rs @@ -1,41 +1,111 @@ use soroban_sdk::{contracterror, contracttype, Address, Bytes, BytesN, Map, Vec}; -#[contracterror(export = false)] -#[derive(Copy, Clone, Debug, PartialEq)] +/// Contract errors. +/// +/// Deliberately renumbered for the v1 interface so the error space is disjoint +/// from the legacy (pre-1.0) contract's 1-9 range. A client decoding an error +/// code < 100 is talking to a legacy wallet. +/// +/// Ranges: +/// - 100-109: signer storage / management +/// - 110-119: auth (`__check_auth`) +/// - 120-129: WebAuthn (secp256r1) verification +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] pub enum Error { - NotFound = 1, - AlreadyExists = 2, - MissingContext = 3, - SignerExpired = 4, - FailedSignerLimits = 5, - FailedPolicySignerLimits = 6, - SignatureKeyValueMismatch = 7, - ClientDataJsonChallengeIncorrect = 8, - JsonParseError = 9, + /// The requested signer does not exist on this smart wallet. + SignerNotFound = 100, + /// `add_signer` was called with a signer key that already exists. + SignerAlreadyExists = 101, + /// The signer's expiration timestamp is in the past. + SignerExpired = 102, + + /// No signer in the signatures map is permitted to authorize one of the + /// requested auth contexts. + MissingContext = 110, + /// A signature's variant does not match the stored signer it claims to be + /// for (e.g. an Ed25519 signature submitted for a Policy signer key). + SignatureKeyValueMismatch = 111, + + /// clientDataJSON exceeds the 1024 byte parse buffer. + ClientDataJsonTooLarge = 120, + /// clientDataJSON is not parseable JSON (or is missing required fields). + ClientDataJsonParseError = 121, + /// The challenge in clientDataJSON does not match the base64url-encoded + /// signature payload. This binds the WebAuthn assertion to the Soroban + /// authorization entry and MUST NOT be weakened. + ClientDataJsonChallengeIncorrect = 122, + /// clientDataJSON `type` is not "webauthn.get". + InvalidWebAuthnType = 123, + /// authenticatorData is shorter than the WebAuthn minimum of 37 bytes + /// (rpIdHash 32 + flags 1 + signCount 4). + InvalidAuthenticatorData = 124, + /// The authenticator did not set the User Present (UP) flag. User + /// Verification (UV) is deliberately NOT required; requiring UP only + /// keeps silent, non-interactive assertions out while remaining + /// compatible with authenticators that don't do user verification. + UserPresenceRequired = 125, } -#[contracttype(export = false)] -#[derive(Clone, Debug, PartialEq)] -pub struct SignerExpiration(pub Option); +/// Optional expiration for a signer as a UNIX timestamp in seconds, INCLUSIVE: +/// the signer is valid while `ledger timestamp <= expiration` and expired once +/// `ledger timestamp > expiration`. `None` never expires. +/// +/// v1 breaking change: this was a ledger sequence number pre-1.0. Timestamps +/// don't drift with changes to ledger close time (e.g. CAP-0070 dynamic +/// timing), which ledger-sequence expirations did. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerExpiration(pub Option); -#[contracttype(export = false)] -#[derive(Clone, Debug, PartialEq)] -// Map of contexts this signer can authorize if present in the __check_auth auth_contexts list -// Map value is a list of SignerKeys which must all be present in the __check_auth signatures list in order for the signer to authorize the context -// e.g. a policy runs on a SAC token to check how much it's withdrawing and also requires a signature from an additional ed25519 signer -// e.g. an ed25519 signer can only be used to authorize a specific contract's invocations and no further keys are required +/// Restrictions on which auth contexts a signer may authorize. +/// +/// - `None`: unlimited. The signer can authorize anything, including +/// `CreateContract*` (deploy) contexts and this wallet's own admin +/// functions. +/// - `Some(empty map)`: NO permissions (fail-closed). The signer can authorize +/// nothing except removing itself (see below). v1 breaking change: pre-1.0 +/// an empty map meant unlimited, leaving two unlimited encodings and no +/// "none" encoding. +/// - `Some({address -> None})`: the signer may authorize any invocation of +/// contract `address`, with no co-signers required. +/// - `Some({address -> Some([keys])})`: the signer may authorize invocations +/// of contract `address` only if every listed key also passes: non-policy +/// keys must be present in the transaction's signatures map (and therefore +/// fully verified in pass 2 of `__check_auth`); policy keys are invoked via +/// `policy__` for the specific context (and, if the policy key is also +/// stored on this wallet, its own stored limits are verified recursively, +/// bounded by a fixed depth guard). +/// +/// Notes: +/// - Deploy permission is NOT grantable through limits: `CreateContract*` +/// contexts require an unlimited (`None`) signer. (Pre-1.0 a limits entry +/// for the wallet's own address doubled as deploy permission.) +/// - A limited signer may ALWAYS authorize `remove_signer(its own key)` on +/// this wallet, regardless of its limits map, and without co-signer +/// requirements. Self-removal is never escalation. +/// - Granting a limits entry for the wallet's own address grants the wallet's +/// admin surface (`add_signer`, `update_signer`, `remove_signer`, +/// `upgrade`). A signer that can add signers can add an unlimited signer, +/// so treat such a grant as equivalent to full control of the wallet. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SignerLimits(pub Option>>>); -#[contracttype(export = false)] -#[derive(Clone, Debug, PartialEq)] +/// Which durability a signer entry is stored under. At most one entry exists +/// per signer key; lookups check Temporary before Persistent. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum SignerStorage { Persistent, Temporary, } -#[contracttype(export = false)] -#[derive(Clone, Debug, PartialEq)] +/// Full signer description used by `__constructor`, `add_signer` and +/// `update_signer`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum Signer { Policy(Address, SignerExpiration, SignerLimits, SignerStorage), Ed25519(BytesN<32>, SignerExpiration, SignerLimits, SignerStorage), @@ -48,38 +118,50 @@ pub enum Signer { ), } -#[contracttype(export = false)] -#[derive(Clone, Debug, PartialEq)] +/// Storage key identifying a signer. Secp256r1 carries the WebAuthn +/// credential id (`keyId`). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum SignerKey { Policy(Address), Ed25519(BytesN<32>), Secp256r1(Bytes), } -#[contracttype(export = false)] -#[derive(Clone, Debug, PartialEq)] +/// Stored signer value. Secp256r1 carries the SEC-1 uncompressed public key. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum SignerVal { Policy(SignerExpiration, SignerLimits), Ed25519(SignerExpiration, SignerLimits), Secp256r1(BytesN<65>, SignerExpiration, SignerLimits), } -#[contracttype(export = false)] -#[derive(Clone, Debug, PartialEq)] +/// A WebAuthn assertion over the Soroban authorization payload. The signed +/// message is `authenticator_data || sha256(client_data_json)` and the +/// payload binding lives in clientDataJSON's `challenge` field. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct Secp256r1Signature { pub authenticator_data: Bytes, pub client_data_json: Bytes, pub signature: BytesN<64>, } -#[contracttype(export = false)] -#[derive(Clone, Debug, PartialEq)] +/// A signature entry in the signatures map. `Policy` carries no signature +/// material: inclusion of the policy key authorizes an on-chain `policy__` +/// check instead. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum Signature { Policy, Ed25519(BytesN<64>), Secp256r1(Secp256r1Signature), } -#[contracttype(export = false)] -#[derive(Clone, Debug, PartialEq)] +/// The `__check_auth` signature object: a map of signer keys to signatures. +/// Map ordering is the host's ScVal ordering. EVERY entry must verify (pass +/// 2 of `__check_auth`) — include only signatures that are needed. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct Signatures(pub Map); diff --git a/contracts/smart-wallet/Cargo.toml b/contracts/smart-wallet/Cargo.toml index 23c75cc..e8f80f4 100644 --- a/contracts/smart-wallet/Cargo.toml +++ b/contracts/smart-wallet/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "smart-wallet" -version = "0.5.0" -authors = ["Stellar Development Foundation "] -license = "Apache-2.0" -edition = "2021" -publish = false +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +publish.workspace = true [lib] crate-type = ["cdylib", "rlib"] @@ -26,4 +26,4 @@ ed25519-dalek = { workspace = true } base64 = { workspace = true } sha256 = { workspace = true } sample-policy = { workspace = true, features = ["testutils"] } -example-contract = { workspace = true, features = ["testutils"] } \ No newline at end of file +example-contract = { workspace = true, features = ["testutils"] } diff --git a/contracts/smart-wallet/src/context.rs b/contracts/smart-wallet/src/context.rs index 5bbec68..f5ca9d3 100644 --- a/contracts/smart-wallet/src/context.rs +++ b/contracts/smart-wallet/src/context.rs @@ -1,91 +1,160 @@ -use smart_wallet_interface::types::{Signatures, SignerKey, SignerLimits}; +use smart_wallet_interface::{ + types::{Signatures, SignerKey, SignerLimits, SignerVal}, + PolicyClient, +}; use soroban_sdk::{ auth::{Context, ContractContext}, - Env, FromVal, Symbol, + vec, Env, Symbol, TryFromVal, }; -use crate::signer::verify_signer_limit_keys; +use crate::signer::{get_signer_val_storage, is_signer_expired, signer_expiration}; + +/// Cap on nested policy-limit verification (a stored policy key inside a +/// signer's limits has its own stored limits verified recursively, which can +/// cycle: A requires B, B requires A). Depth past this bound fails closed — +/// the candidate simply doesn't cover the context. +const MAX_LIMIT_KEY_DEPTH: u32 = 4; +/// Decide whether `signer_key` (with `signer_limits`) may authorize +/// `context`. +/// +/// PURELY BOOLEAN by design: rejecting a candidate must never panic, so that +/// other signers in the signatures map can still cover the context. Uncovered +/// contexts surface as `Error::MissingContext` at the `__check_auth` level. +/// +/// Expiration of the candidate itself is NOT checked here — every signatures +/// map entry gets a single-point expiration check in pass 2 of +/// `__check_auth`. Stored policy keys referenced inside limits ARE +/// expiration-checked here (boolean), because they need not appear in the +/// signatures map and would otherwise never be checked. pub fn verify_context( env: &Env, context: &Context, signer_key: &SignerKey, signer_limits: &SignerLimits, signatures: &Signatures, + depth: u32, ) -> bool { - match &signer_limits.0 { - None => true, // Signer has no limits, it can do anything - Some(signer_limits) => { - if signer_limits.is_empty() { - return true; + let limits = match &signer_limits.0 { + // No limits: the signer can authorize anything. + None => return true, + // Some(map): fail-closed. An empty map covers nothing (except + // self-removal, below). + Some(limits) => limits, + }; + + match context { + Context::Contract(ContractContext { + contract, + fn_name, + args, + }) => { + // A limited signer may always remove itself, regardless of its + // limits map and without co-signer requirements. Gated on the + // context being THIS wallet's `remove_signer` with the signer's + // own key as the argument; a non-SignerKey argument is simply + // not a self-removal (no panic). + if *contract == env.current_contract_address() + && *fn_name == Symbol::new(env, "remove_signer") + { + if let Some(arg) = args.get(0) { + if let Ok(removed_key) = SignerKey::try_from_val(env, &arg) { + if removed_key == *signer_key { + return true; + } + } + } } - match context { - Context::Contract(ContractContext { - contract, - fn_name, - args, - }) => { - match signer_limits.get(contract.clone()) { - None => false, // signer limitations not met - Some(signer_limits_keys) => { - // If this signer has a smart wallet context limit, limit that context to only removing itself - if *contract == env.current_contract_address() - && *fn_name != Symbol::new(&env, "remove_signer") - || (*fn_name == Symbol::new(&env, "remove_signer") - && SignerKey::from_val(env, &args.get_unchecked(0)) - != *signer_key) - { - return false; // self trying to do something other than remove itself - } + match limits.get(contract.clone()) { + // No entry for this contract: not permitted. + None => false, + Some(required_keys) => verify_signer_limit_keys( + env, + signer_key, + signatures, + &required_keys, + context, + depth, + ), + } + } + // Deploy permission is not grantable through limits: CreateContract* + // contexts require an unlimited signer (handled by the `None` arm + // above). + Context::CreateContractHostFn(_) | Context::CreateContractWithCtorHostFn(_) => false, + } +} + +/// Check a limits entry's co-signer requirements for one context. +/// +/// - Non-policy keys must be present in the signatures map. Their crypto, +/// existence and expiration are enforced by pass 2 of `__check_auth`, +/// which verifies every signatures map entry. +/// - Policy keys need not be in the signatures map (they can be "adjacent" +/// requirements). If the policy key is stored on this wallet it must be +/// unexpired and its own stored limits must cover this context +/// (recursive, depth-guarded). The policy contract is then invoked via +/// `try_policy__` for this single context — a rejecting or failing policy +/// rejects the candidate, never the whole transaction. +fn verify_signer_limit_keys( + env: &Env, + signer_key: &SignerKey, + signatures: &Signatures, + required_keys: &Option>, + context: &Context, + depth: u32, +) -> bool { + let required_keys = match required_keys { + // No co-signer requirements for this contract. + None => return true, + Some(keys) => keys, + }; - verify_signer_limit_keys( - env, - signer_key, - signatures, - &signer_limits_keys, - &context, - ); + for required_key in required_keys.iter() { + match &required_key { + SignerKey::Policy(policy) => { + if depth >= MAX_LIMIT_KEY_DEPTH { + return false; + } - true - } + if let Some((signer_val, _)) = get_signer_val_storage(env, &required_key, true) { + if is_signer_expired(env, signer_expiration(&signer_val)) { + return false; } - } - Context::CreateContractHostFn(_) => { - // Only signers with the smart wallet context signer limit can deploy contracts - match signer_limits.get(env.current_contract_address()) { - None => false, // signer limitations not met - Some(signer_limits_keys) => { - verify_signer_limit_keys( - env, - signer_key, - signatures, - &signer_limits_keys, - &context, - ); - true + if let SignerVal::Policy(_, limits) = &signer_val { + if !verify_context( + env, + context, + &required_key, + limits, + signatures, + depth + 1, + ) { + return false; } } } - Context::CreateContractWithCtorHostFn(_) => { - // Only signers with the smart wallet context signer limit can deploy contracts - match signer_limits.get(env.current_contract_address()) { - None => false, // signer limitations not met - Some(signer_limits_keys) => { - verify_signer_limit_keys( - env, - signer_key, - signatures, - &signer_limits_keys, - &context, - ); - true - } - } + if PolicyClient::new(env, policy) + .try_policy__( + &env.current_contract_address(), + signer_key, + &vec![env, context.clone()], + ) + .is_err() + { + return false; + } + } + _ => { + if !signatures.0.contains_key(required_key.clone()) { + return false; } } } } + + true } diff --git a/contracts/smart-wallet/src/lib.rs b/contracts/smart-wallet/src/lib.rs index 238c423..88f762a 100644 --- a/contracts/smart-wallet/src/lib.rs +++ b/contracts/smart-wallet/src/lib.rs @@ -1,14 +1,18 @@ #![no_std] use context::verify_context; -use signer::{get_signer_val_storage, process_signer, store_signer, verify_signer_expiration}; +use signer::{ + get_signer_val_storage, is_signer_expired, process_signer, signer_expiration, signer_limits, + store_signer, +}; use smart_wallet_interface::{ + events::{SignerAdded, SignerRemoved, SignerUpdated, Upgraded}, types::{Error, Signature, Signatures, Signer, SignerKey, SignerStorage, SignerVal}, PolicyClient, SmartWalletInterface, }; use soroban_sdk::{ auth::{Context, CustomAccountInterface}, - contract, contractimpl, + contract, contractimpl, contractmeta, crypto::Hash, panic_with_error, symbol_short, BytesN, Env, Symbol, Vec, }; @@ -19,7 +23,6 @@ mod base64_url; mod context; mod signer; mod storage; -mod types; mod verify; #[path = "./tests/test.rs"] @@ -27,82 +30,139 @@ mod test; #[path = "./tests/test_extra.rs"] mod test_extra; +contractmeta!(key = "binver", val = env!("CARGO_PKG_VERSION")); + +/// Instance storage key caching the wasm hash installed by the most recent +/// `upgrade`. Absent until the first upgrade (a contract cannot read its own +/// executable hash from the host). Sourced for `Upgraded.old_hash`. +const WASM_HASH: Symbol = symbol_short!("wasm_hash"); + #[contract] pub struct Contract; -const EVENT_TAG: Symbol = symbol_short!("sw_v1"); -const INITIALIZED: Symbol = symbol_short!("init"); +impl Contract { + fn add_signer_impl(env: &Env, signer: Signer) -> Result<(), Error> { + let (signer_key, signer_val, signer_storage) = process_signer(signer); + + store_signer(env, &signer_key, &signer_val, &signer_storage, false)?; + + // Policy signers get their install hook invoked (the policy sees the + // wallet as its authenticated invoker). A failing install aborts the + // add — policies must opt in to being attached. + if let SignerKey::Policy(policy) = &signer_key { + PolicyClient::new(env, policy).install(&env.current_contract_address()); + } + + extend_instance(env); + + SignerAdded { + key: signer_key, + val: signer_val, + storage: signer_storage, + } + .publish(env); + + Ok(()) + } +} #[contractimpl] impl SmartWalletInterface for Contract { fn __constructor(env: Env, signer: Signer) { - Self::add_signer(env, signer); - } - fn add_signer(env: Env, signer: Signer) { - if env - .storage() - .instance() - .get::(&INITIALIZED) - .unwrap_or(false) - { - env.current_contract_address().require_auth(); - } else { - env.storage() - .instance() - .set::(&INITIALIZED, &true); + // Deploy-time-only initialization (CAP-0058 constructor). There is no + // init flag and no un-authenticated first-add path. + if let Err(error) = Self::add_signer_impl(&env, signer) { + panic_with_error!(env, error); } + } - let (signer_key, signer_val, signer_storage) = process_signer(signer); - - store_signer(&env, &signer_key, &signer_val, &signer_storage, false); - - extend_instance(&env); + fn add_signer(env: Env, signer: Signer) -> Result<(), Error> { + env.current_contract_address().require_auth(); - env.events().publish( - (EVENT_TAG, symbol_short!("add"), signer_key), - (signer_val, signer_storage), - ); + Self::add_signer_impl(&env, signer) } - fn update_signer(env: Env, signer: Signer) { + + fn update_signer(env: Env, signer: Signer) -> Result<(), Error> { env.current_contract_address().require_auth(); let (signer_key, signer_val, signer_storage) = process_signer(signer); - store_signer(&env, &signer_key, &signer_val, &signer_storage, true); + let old_storage = store_signer(&env, &signer_key, &signer_val, &signer_storage, true)? + .ok_or(Error::SignerNotFound)?; extend_instance(&env); - env.events().publish( - (EVENT_TAG, symbol_short!("update"), signer_key), - (signer_val, signer_storage), - ); + SignerUpdated { + key: signer_key, + val: signer_val, + storage: signer_storage, + old_storage, + } + .publish(&env); + + Ok(()) } - fn remove_signer(env: Env, signer_key: SignerKey) { + + fn remove_signer(env: Env, signer_key: SignerKey) -> Result<(), Error> { env.current_contract_address().require_auth(); - match get_signer_val_storage(&env, &signer_key, false) { - Some((_, signer_storage)) => match signer_storage { - SignerStorage::Persistent => { - env.storage().persistent().remove::(&signer_key); - } - SignerStorage::Temporary => { - env.storage().temporary().remove::(&signer_key); - } - }, - None => panic_with_error!(env, Error::NotFound), + let (_, signer_storage) = + get_signer_val_storage(&env, &signer_key, false).ok_or(Error::SignerNotFound)?; + + match &signer_storage { + SignerStorage::Persistent => { + env.storage().persistent().remove::(&signer_key); + } + SignerStorage::Temporary => { + env.storage().temporary().remove::(&signer_key); + } + } + + // Best-effort uninstall: a failing (or malicious) policy must never + // be able to block its own removal from the wallet. + if let SignerKey::Policy(policy) = &signer_key { + let _ = PolicyClient::new(&env, policy).try_uninstall(&env.current_contract_address()); } extend_instance(&env); - env.events() - .publish((EVENT_TAG, symbol_short!("remove"), signer_key), ()); + SignerRemoved { + key: signer_key, + storage: signer_storage, + } + .publish(&env); + + Ok(()) } - fn update_contract_code(env: Env, hash: BytesN<32>) { + + fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error> { env.current_contract_address().require_auth(); - env.deployer().update_current_contract_wasm(hash); + let old_hash = env + .storage() + .instance() + .get::>(&WASM_HASH); + + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + + env.storage() + .instance() + .set::>(&WASM_HASH, &new_wasm_hash); extend_instance(&env); + + Upgraded { + old_hash, + new_hash: new_wasm_hash, + } + .publish(&env); + + Ok(()) + } + + fn get_signer(env: Env, signer_key: SignerKey) -> Option { + get_signer_val_storage(&env, &signer_key, false).map(|(signer_val, _)| signer_val) } } @@ -118,88 +178,89 @@ impl CustomAccountInterface for Contract { signatures: Signatures, auth_contexts: Vec, ) -> Result<(), Error> { - // Check all contexts for an authorizing signature + // Pass 1 — context coverage. Every context must be authorizable by at + // least one signer in the signatures map. `verify_context` is purely + // boolean: a candidate that can't cover a context never poisons the + // attempt for other candidates. Expiration is deliberately NOT + // checked here — pass 2 is the single point of truth and fails the + // whole auth if ANY map entry is expired. for context in auth_contexts.iter() { - 'check: loop { - for (signer_key, _) in signatures.0.iter() { - if let Some((signer_val, _)) = get_signer_val_storage(&env, &signer_key, false) - { - let (signer_expiration, signer_limits) = match signer_val { - SignerVal::Policy(signer_expiration, signer_limits) => { - (signer_expiration, signer_limits) - } - SignerVal::Ed25519(signer_expiration, signer_limits) => { - (signer_expiration, signer_limits) - } - SignerVal::Secp256r1(_, signer_expiration, signer_limits) => { - (signer_expiration, signer_limits) - } - }; - - verify_signer_expiration(&env, signer_expiration); - - if verify_context(&env, &context, &signer_key, &signer_limits, &signatures) - { - break 'check; - } else { - continue; - } + let mut covered = false; + + for (signer_key, _) in signatures.0.iter() { + if let Some((signer_val, _)) = get_signer_val_storage(&env, &signer_key, false) { + if verify_context( + &env, + &context, + &signer_key, + signer_limits(&signer_val), + &signatures, + 0, + ) { + covered = true; + break; } } + } - panic_with_error!(env, Error::MissingContext); + if !covered { + return Err(Error::MissingContext); } } - // Check all signatures for a matching context + // Pass 2 — verify EVERY signatures map entry: it must be stored on + // this wallet, unexpired, and its signature material must verify. + // Include only signatures that are needed; an invalid or expired + // extra entry fails the entire authorization (deterministically, + // regardless of map order). for (signer_key, signature) in signatures.0.iter() { - // This is probably the only right place to verify_signer_expiration for crypto keys - - match get_signer_val_storage(&env, &signer_key, true) { - None => panic_with_error!(env, Error::NotFound), - Some((signer_val, _)) => { - match signature { - Signature::Policy => { - // If there's a policy signer in the signatures map we call it as a full forward of this __check_auth's Vec - if let SignerKey::Policy(policy) = &signer_key { - PolicyClient::new(&env, policy).policy__( - &env.current_contract_address(), - &signer_key, - &auth_contexts, - ); - continue; - } - - panic_with_error!(&env, Error::SignatureKeyValueMismatch) - } - Signature::Ed25519(signature) => { - if let SignerKey::Ed25519(public_key) = &signer_key { - env.crypto().ed25519_verify( - &public_key, - &signature_payload.clone().into(), - &signature, - ); - continue; - } - - panic_with_error!(&env, Error::SignatureKeyValueMismatch) - } - Signature::Secp256r1(signature) => { - if let SignerVal::Secp256r1(public_key, _, _) = signer_val { - verify_secp256r1_signature( - &env, - &signature_payload, - &public_key, - signature, - ); - continue; - } - - panic_with_error!(&env, Error::SignatureKeyValueMismatch) - } + let (signer_val, _) = + get_signer_val_storage(&env, &signer_key, true).ok_or(Error::SignerNotFound)?; + + if is_signer_expired(&env, signer_expiration(&signer_val)) { + return Err(Error::SignerExpired); + } + + match signature { + Signature::Policy => { + if let SignerKey::Policy(policy) = &signer_key { + // Policy-as-signature sees the FULL context list (a + // policy used inside another signer's limits sees one + // context at a time). A rejecting policy fails the + // whole authorization. + PolicyClient::new(&env, policy).policy__( + &env.current_contract_address(), + &signer_key, + &auth_contexts, + ); + } else { + return Err(Error::SignatureKeyValueMismatch); + } + } + Signature::Ed25519(signature) => { + if let SignerKey::Ed25519(public_key) = &signer_key { + env.crypto().ed25519_verify( + public_key, + &signature_payload.clone().into(), + &signature, + ); + } else { + return Err(Error::SignatureKeyValueMismatch); } } - }; + Signature::Secp256r1(signature) => { + if let SignerVal::Secp256r1(public_key, _, _) = &signer_val { + verify_secp256r1_signature( + &env, + &signature_payload, + public_key, + signature, + )?; + } else { + return Err(Error::SignatureKeyValueMismatch); + } + } + } } extend_instance(&env); diff --git a/contracts/smart-wallet/src/signer.rs b/contracts/smart-wallet/src/signer.rs index 1965c68..e1d3439 100644 --- a/contracts/smart-wallet/src/signer.rs +++ b/contracts/smart-wallet/src/signer.rs @@ -1,10 +1,9 @@ -use smart_wallet_interface::{ - types::{Error, Signatures, Signer, SignerExpiration, SignerKey, SignerStorage, SignerVal}, - PolicyClient, +use smart_wallet_interface::types::{ + Error, Signer, SignerExpiration, SignerKey, SignerStorage, SignerVal, }; -use soroban_sdk::{auth::Context, panic_with_error, vec, Env, Vec}; +use soroban_sdk::Env; -use crate::{context::verify_context, storage::extend_signer_key}; +use crate::storage::extend_signer_key; pub fn process_signer(signer: Signer) -> (SignerKey, SignerVal, SignerStorage) { match signer { @@ -26,67 +25,64 @@ pub fn process_signer(signer: Signer) -> (SignerKey, SignerVal, SignerStorage) { } } +/// Store a signer entry. `update: false` requires the key to be new, +/// `update: true` requires it to exist. Returns the previous storage +/// durability when updating (for the `SignerUpdated` event's `old_storage`). pub fn store_signer( env: &Env, signer_key: &SignerKey, signer_val: &SignerVal, signer_storage: &SignerStorage, update: bool, -) { - // Include this before the `.set` calls so it doesn't read them as previous values - let previous_signer_val_and_storage: Option<(SignerVal, SignerStorage)> = - get_signer_val_storage(env, signer_key, false); +) -> Result, Error> { + let previous = get_signer_val_storage(env, signer_key, false); + + match (&previous, update) { + (Some(_), false) => return Err(Error::SignerAlreadyExists), + (None, true) => return Err(Error::SignerNotFound), + _ => {} + } - // Add and extend the signer key in the appropriate storage let is_persistent = match signer_storage { SignerStorage::Persistent => { env.storage() .persistent() .set::(signer_key, signer_val); - true } SignerStorage::Temporary => { env.storage() .temporary() .set::(signer_key, signer_val); - false } }; extend_signer_key(env, signer_key, is_persistent); - match previous_signer_val_and_storage { - Some((_, previous_signer_storage)) => { - // Panic if the signer key already exists and we're not update it - if !update { - panic_with_error!(env, Error::AlreadyExists); - } - - // Remove signer key in the opposing storage if it exists - match previous_signer_storage { - SignerStorage::Persistent => { - if !is_persistent { - env.storage().persistent().remove::(signer_key); - } - } - SignerStorage::Temporary => { - if is_persistent { - env.storage().temporary().remove::(signer_key); - } + // An update that flips durability leaves the old entry behind — remove it + // so the "at most one entry per signer key" invariant holds. + if let Some((_, previous_storage)) = &previous { + match previous_storage { + SignerStorage::Persistent => { + if !is_persistent { + env.storage().persistent().remove::(signer_key); } } - } - None => { - // Panic if we're update a signer key that doesn't exist - if update { - panic_with_error!(env, Error::NotFound); + SignerStorage::Temporary => { + if is_persistent { + env.storage().temporary().remove::(signer_key); + } } } } + + Ok(previous.map(|(_, storage)| storage)) } +/// Look up a signer entry, checking Temporary before Persistent (invariant: +/// at most one entry exists per key, but the lookup order is load-bearing for +/// determinism and indexers mirror it). pub fn get_signer_val_storage( env: &Env, signer_key: &SignerKey, @@ -104,77 +100,46 @@ pub fn get_signer_val_storage( Some((signer_val, SignerStorage::Temporary)) } - None => { - match env - .storage() - .persistent() - .get::(signer_key) - { - Some(signer_val) => { - if extend_ttl { - extend_signer_key(env, signer_key, true); - } - - Some((signer_val, SignerStorage::Persistent)) + None => match env + .storage() + .persistent() + .get::(signer_key) + { + Some(signer_val) => { + if extend_ttl { + extend_signer_key(env, signer_key, true); } - None => None, + + Some((signer_val, SignerStorage::Persistent)) } - } + None => None, + }, } } -pub fn verify_signer_expiration(env: &Env, signer_expiration: SignerExpiration) { - if let Some(signer_expiration) = signer_expiration.0 { - if env.ledger().sequence() > signer_expiration { - // Note we're not removing this expired signer. Probably fine but storage will fill up with expired signers - // This is fine from the protocol perspective because persistent entries will be archived and temporary entries will be evicted - // However on the indexer side we'll want to filter out signers which are expired - panic_with_error!(env, Error::SignerExpired); - } +pub fn signer_expiration(signer_val: &SignerVal) -> &SignerExpiration { + match signer_val { + SignerVal::Policy(signer_expiration, _) => signer_expiration, + SignerVal::Ed25519(signer_expiration, _) => signer_expiration, + SignerVal::Secp256r1(_, signer_expiration, _) => signer_expiration, } } -pub fn verify_signer_limit_keys( - env: &Env, - signer_key: &SignerKey, - signatures: &Signatures, - signer_limits_keys: &Option>, - context: &Context, -) { - if let Some(signer_limits_keys) = signer_limits_keys { - for signer_limits_key in signer_limits_keys.iter() { - // Policies SignerLimits don't need to exist in the signatures map, or be stored on the smart wallet for that matter, they can be adjacent as long as they pass their own policy__ check - if let SignerKey::Policy(policy) = &signer_limits_key { - // In the case of a policy signer in the SignerLimits map we need to verify it if that key has been saved to the smart wallet - // NOTE watch out for infinity loops. If a policy calls itself this will indefinitely recurse - if let Some((signer_limits_val, _)) = - get_signer_val_storage(env, &signer_limits_key, true) - { - if let SignerVal::Policy(signer_expiration, signer_limits) = signer_limits_val { - verify_signer_expiration(env, signer_expiration); - - if !verify_context( - env, - context, - &signer_limits_key, - &signer_limits, - signatures, - ) { - panic_with_error!(env, Error::FailedPolicySignerLimits) - } - } - } +pub fn signer_limits(signer_val: &SignerVal) -> &smart_wallet_interface::types::SignerLimits { + match signer_val { + SignerVal::Policy(_, signer_limits) => signer_limits, + SignerVal::Ed25519(_, signer_limits) => signer_limits, + SignerVal::Secp256r1(_, _, signer_limits) => signer_limits, + } +} - PolicyClient::new(&env, policy).policy__( - &env.current_contract_address(), - signer_key, - &vec![env, context.clone()], - ); - // For every other SignerLimits key, it must exist in the signatures map and thus exist as a signer on the smart wallet - } else if !signatures.0.contains_key(signer_limits_key.clone()) { - // if any required key is missing this contract invocation is invalid - panic_with_error!(env, Error::FailedSignerLimits) - } - } +/// Expiration is a UNIX timestamp in seconds, inclusive: expired once +/// `ledger timestamp > expiration`. Expired signers are not pruned from +/// storage (persistent entries archive, temporary entries evict); indexers +/// filter expiration themselves. +pub fn is_signer_expired(env: &Env, signer_expiration: &SignerExpiration) -> bool { + match signer_expiration.0 { + Some(expiration) => env.ledger().timestamp() > expiration, + None => false, } } diff --git a/contracts/smart-wallet/src/storage.rs b/contracts/smart-wallet/src/storage.rs index 46f8784..9b0e689 100644 --- a/contracts/smart-wallet/src/storage.rs +++ b/contracts/smart-wallet/src/storage.rs @@ -1,6 +1,16 @@ +//! TTL policy: every successful authentication (and every signer mutation) +//! bumps the instance — and any touched signer entries — to the maximum TTL. +//! This is a deliberate rent PREPAY: an actively-used wallet keeps itself and +//! its signers alive indefinitely, funded by the transactions that use it. +//! The extend threshold is one week of ledgers below max, so at most one +//! bump per entry per week is actually written. + use smart_wallet_interface::types::SignerKey; use soroban_sdk::Env; +/// One week of ledgers at the historical 5s close time. Close time can drift +/// (CAP-0070 dynamic timing); this constant only shapes how often TTL bumps +/// rewrite state, not any auth semantics, so drift is harmless. const WEEK_OF_LEDGERS: u32 = 60 * 60 * 24 / 5 * 7; pub fn extend_instance(env: &Env) { @@ -10,6 +20,7 @@ pub fn extend_instance(env: &Env) { .instance() .extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl); } + pub fn extend_signer_key(env: &Env, signer_key: &SignerKey, persistent: bool) { let max_ttl = env.storage().max_ttl(); diff --git a/contracts/smart-wallet/src/tests/test.rs b/contracts/smart-wallet/src/tests/test.rs index 42b7c2f..f23c31e 100644 --- a/contracts/smart-wallet/src/tests/test.rs +++ b/contracts/smart-wallet/src/tests/test.rs @@ -56,7 +56,7 @@ fn test() { super_ed25519_bytes.copy_into_slice(&mut super_ed25519_array); let super_ed25519_bytes = BytesN::from_array(&env, &super_ed25519_array); - let super_ed25519_signer_key = SignerKey::Ed25519(super_ed25519_bytes.clone()); + let _super_ed25519_signer_key = SignerKey::Ed25519(super_ed25519_bytes.clone()); // let wallet_address = env.register( @@ -81,7 +81,7 @@ fn test() { let sac = env.register_stellar_asset_contract_v2(sac_admin); let sac_address = sac.address(); let sac_admin_client = token::StellarAssetClient::new(&env, &sac_address); - let sac_client = token::Client::new(&env, &sac_address); + let _sac_client = token::Client::new(&env, &sac_address); // sac_admin_client diff --git a/contracts/smart-wallet/src/tests/test_extra.rs b/contracts/smart-wallet/src/tests/test_extra.rs index e2375d3..cff7a46 100644 --- a/contracts/smart-wallet/src/tests/test_extra.rs +++ b/contracts/smart-wallet/src/tests/test_extra.rs @@ -1,6 +1,5 @@ #![cfg(test)] -use std::println; extern crate std; use example_contract::{Contract as ExampleContract, ContractClient as ExampleContractClient}; @@ -8,14 +7,13 @@ use smart_wallet_interface::types::{ Signature, Signatures, Signer, SignerExpiration, SignerKey, SignerLimits, SignerStorage, }; use soroban_sdk::{ - map, symbol_short, + map, testutils::EnvTestConfig, xdr::{ ContractExecutable, ContractId, ContractIdPreimage, ContractIdPreimageFromAddress, CreateContractArgsV2, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Limits, - ScAddress, ScVal, SorobanAddressCredentials, SorobanAuthorizationEntry, - SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, ToXdr, Uint256, - VecM, WriteXdr, + ScAddress, SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanAuthorizedFunction, + SorobanAuthorizedInvocation, SorobanCredentials, ToXdr, Uint256, VecM, WriteXdr, }, Address, Bytes, BytesN, Env, }; @@ -24,23 +22,11 @@ use stellar_strkey::{ed25519, Strkey}; use crate::Contract; use ed25519_dalek::{Signer as _, SigningKey}; -use base64::{engine::general_purpose::URL_SAFE, Engine as _}; - mod sample_policy { - use crate::types::SignerKey; use soroban_sdk::auth::Context; soroban_sdk::contractimport!(file = "../target/wasm32v1-none/release/sample_policy.wasm"); } -#[test] -fn test() { - let env = Env::default(); - - let test = symbol_short!("sw_v1"); - - println!("{:?}", test.to_xdr(&env)); -} - #[test] fn test_deploy_contract() { let mut env: Env = Env::default(); @@ -84,7 +70,6 @@ fn test_deploy_contract() { SignerStorage::Persistent, ),), ); - // let wallet_client = ContractClient::new(&env, &wallet_address); let example_contract_address = env.register(ExampleContract, ()); let example_contract_client = ExampleContractClient::new(&env, &example_contract_address); @@ -144,17 +129,3 @@ fn test_deploy_contract() { .set_auths(&[root_auth]) .deploy(&wallet_address, &wasm_hash); } - -#[test] -fn who_am_i() { - let env: Env = Env::default(); - - let none = None::
; - let none = none.to_xdr(&env); - let mut none_bytes: [u8; 4] = [0; 4]; - - none.copy_into_slice(&mut none_bytes); - - println!("{:?}", URL_SAFE.encode(none_bytes)); - println!("{:?}", ScVal::Void.to_xdr_base64(Limits::none()).unwrap()); -} diff --git a/contracts/smart-wallet/src/types.rs b/contracts/smart-wallet/src/types.rs deleted file mode 100644 index e8eb6a8..0000000 --- a/contracts/smart-wallet/src/types.rs +++ /dev/null @@ -1,85 +0,0 @@ -use soroban_sdk::{contracterror, contracttype, Address, Bytes, BytesN, Map, Vec}; - -#[contracterror] -#[derive(Copy, Clone, Debug, PartialEq)] -#[repr(u32)] -pub enum Error { - NotFound = 1, - AlreadyExists = 2, - MissingContext = 3, - SignerExpired = 4, - FailedSignerLimits = 5, - FailedPolicySignerLimits = 6, - SignatureKeyValueMismatch = 7, - ClientDataJsonChallengeIncorrect = 8, - JsonParseError = 9, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SignerExpiration(pub Option); - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -// Map of contexts this signer can authorize if present in the __check_auth auth_contexts list -// Map value is a list of SignerKeys which must all be present in the __check_auth signatures list in order for the signer to authorize the context -// e.g. a policy runs on a SAC token to check how much it's withdrawing and also requires a signature from an additional ed25519 signer -// e.g. an ed25519 signer can only be used to authorize a specific contract's invocations and no further keys are required -pub struct SignerLimits(pub Option>>>); - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum SignerStorage { - Persistent, - Temporary, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum Signer { - Policy(Address, SignerExpiration, SignerLimits, SignerStorage), - Ed25519(BytesN<32>, SignerExpiration, SignerLimits, SignerStorage), - Secp256r1( - Bytes, - BytesN<65>, - SignerExpiration, - SignerLimits, - SignerStorage, - ), -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum SignerKey { - Policy(Address), - Ed25519(BytesN<32>), - Secp256r1(Bytes), -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum SignerVal { - Policy(SignerExpiration, SignerLimits), - Ed25519(SignerExpiration, SignerLimits), - Secp256r1(BytesN<65>, SignerExpiration, SignerLimits), -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Secp256r1Signature { - pub authenticator_data: Bytes, - pub client_data_json: Bytes, - pub signature: BytesN<64>, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum Signature { - Policy, - Ed25519(BytesN<64>), - Secp256r1(Secp256r1Signature), -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct Signatures(pub Map); diff --git a/contracts/smart-wallet/src/verify.rs b/contracts/smart-wallet/src/verify.rs index e9f977b..9d5b25c 100644 --- a/contracts/smart-wallet/src/verify.rs +++ b/contracts/smart-wallet/src/verify.rs @@ -1,48 +1,94 @@ use smart_wallet_interface::types::{Error, Secp256r1Signature}; -use soroban_sdk::{crypto::Hash, panic_with_error, BytesN, Env}; +use soroban_sdk::{crypto::Hash, BytesN, Env}; use crate::base64_url; +/// WebAuthn authenticatorData minimum length: rpIdHash (32) + flags (1) + +/// signCount (4). +const AUTHENTICATOR_DATA_MIN_LEN: u32 = 37; +/// Index of the flags byte within authenticatorData. +const AUTHENTICATOR_FLAGS_INDEX: u32 = 32; +/// User Present flag (bit 0). +const FLAG_USER_PRESENT: u8 = 0x01; +/// Parse buffer for clientDataJSON. Browsers emit well under 1KB; anything +/// larger is rejected with a typed error rather than an untyped panic. +const CLIENT_DATA_JSON_MAX_LEN: u32 = 1024; + #[derive(serde::Deserialize)] struct ClientDataJson<'a> { challenge: &'a str, + #[serde(rename = "type")] + ty: &'a str, } +/// Verify a WebAuthn assertion (passkey signature) over the Soroban +/// authorization payload. +/// +/// Checks, in order: +/// 1. authenticatorData is structurally valid (>= 37 bytes) and has the +/// User Present flag set. User Verification (UV) is deliberately NOT +/// required — see `Error::UserPresenceRequired`. +/// 2. clientDataJSON parses, is a "webauthn.get" assertion, and its +/// `challenge` equals base64url(signature_payload). The challenge +/// equality is the ONLY binding between the WebAuthn assertion and the +/// Soroban authorization entry — it is load-bearing and must not be +/// weakened. rpIdHash and origin are deliberately not pinned on-chain: +/// the wallet is rp-agnostic and origin policy is a client-side concern. +/// 3. The secp256r1 signature verifies over +/// sha256(authenticatorData || sha256(clientDataJSON)) per the WebAuthn +/// spec (host panics on an invalid signature). pub fn verify_secp256r1_signature( env: &Env, signature_payload: &Hash<32>, public_key: &BytesN<65>, signature: Secp256r1Signature, -) { +) -> Result<(), Error> { let Secp256r1Signature { mut authenticator_data, client_data_json, signature, } = signature; - authenticator_data.extend_from_array(&env.crypto().sha256(&client_data_json).to_array()); + if authenticator_data.len() < AUTHENTICATOR_DATA_MIN_LEN { + return Err(Error::InvalidAuthenticatorData); + } - env.crypto().secp256r1_verify( - &public_key, - &env.crypto().sha256(&authenticator_data), - &signature, - ); + let flags = authenticator_data + .get(AUTHENTICATOR_FLAGS_INDEX) + .ok_or(Error::InvalidAuthenticatorData)?; - // Parse the client data JSON, extracting the base64 url encoded challenge. - let client_data_json = client_data_json.to_buffer::<1024>(); // <- TODO why 1024? - let client_data_json = client_data_json.as_slice(); - let (client_data_json, _): (ClientDataJson, _) = - serde_json_core::de::from_slice(client_data_json) - .unwrap_or_else(|_| panic_with_error!(env, Error::JsonParseError)); + if flags & FLAG_USER_PRESENT == 0 { + return Err(Error::UserPresenceRequired); + } - // Build what the base64 url challenge is expecting. - let mut expected_challenge = [0u8; 43]; + if client_data_json.len() > CLIENT_DATA_JSON_MAX_LEN { + return Err(Error::ClientDataJsonTooLarge); + } + + let client_data_json_buffer = client_data_json.to_buffer::<1024>(); + let (client_data, _): (ClientDataJson, _) = + serde_json_core::de::from_slice(client_data_json_buffer.as_slice()) + .map_err(|_| Error::ClientDataJsonParseError)?; + if client_data.ty != "webauthn.get" { + return Err(Error::InvalidWebAuthnType); + } + + // base64url of 32 bytes is exactly 43 chars (unpadded). + let mut expected_challenge = [0u8; 43]; base64_url::encode(&mut expected_challenge, &signature_payload.to_array()); - // Check that the challenge inside the client data JSON that was signed is identical to the expected challenge. - // TODO is this check actually necessary or is the secp256r1_verify sufficient? - if client_data_json.challenge.as_bytes() != expected_challenge { - panic_with_error!(env, Error::ClientDataJsonChallengeIncorrect) + if client_data.challenge.as_bytes() != expected_challenge { + return Err(Error::ClientDataJsonChallengeIncorrect); } + + authenticator_data.extend_from_array(&env.crypto().sha256(&client_data_json).to_array()); + + env.crypto().secp256r1_verify( + public_key, + &env.crypto().sha256(&authenticator_data), + &signature, + ); + + Ok(()) } From 50d109bf22733378fec7b16441c9ec86de88699f Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 21:37:45 -0400 Subject: [PATCH 07/30] B2 SDK: signing pipeline + DI managers + WebAuthn ops (compareScVal, F7 ownership) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ground-up rewrite of the signing pipeline into DI managers (todo 949): - signers.ts: PasskeySigner/Ed25519Signer/PolicySigner replace the keyId|keypair|policy option trio with explicit signer objects. - kit/auth-payload.ts: host-order compareScVal + Signatures map read/upsert/sort (replaces the localeCompare bug) + SignerKey/Signature encoding via bindings Spec. - kit/webauthn-ops.ts: registration/authentication ceremonies with a RANDOM challenge (was the static "stellaristhebetterblockchain") and rpId from the constructor; SPKI public-key extraction via utils. - kit/deploy-ops.ts: configurable deploySource (default preserves the canonical "kalepail" deterministic deployer); deterministic address derivation. - kit/wallet-ops.ts: add/update/remove signer builders + SignerKey/SignerLimits encoding + getSigner via RPC ledger read (temporary-before-persistent). - kit/tx-ops.ts: single explicit AssembledTransaction input (kills the lossy tri-input coercion), calculateExpiration, restoreFootprint helper. - managers/{credential,signer,submission}-manager.ts: vi.fn-testable DI managers. - kit.ts: full facade rewrite wiring the managers with late-bound closures; connectWallet ownership verification (#601 F7) — a keyId MUST resolve to a live signer on the resolved wallet (closes #598 F3 at the SDK layer), plus opt-in wasm-hash check; typed events (walletCreated/walletConnected/...). Deleted every P27 probe shim, dead commented experiment, and the legacy factory-address fallback. pnpm build (+ verify-esm) and vitest (90 tests) green. Co-Authored-By: Claude Opus 4.8 --- src/index.ts | 18 +- src/kit.ts | 1223 ++++++++++----------------- src/kit/auth-payload.ts | 181 +++- src/kit/deploy-ops.ts | 95 +++ src/kit/signatures.test.ts | 131 +++ src/kit/tx-ops.ts | 207 +++++ src/kit/wallet-ops.ts | 204 +++++ src/kit/webauthn-ops.ts | 141 +++ src/managers/credential-manager.ts | 76 ++ src/managers/index.ts | 13 + src/managers/signer-manager.test.ts | 129 +++ src/managers/signer-manager.ts | 181 ++++ src/managers/submission-manager.ts | 99 +++ src/signers.test.ts | 114 +++ src/signers.ts | 196 +++++ 15 files changed, 2207 insertions(+), 801 deletions(-) create mode 100644 src/kit/deploy-ops.ts create mode 100644 src/kit/signatures.test.ts create mode 100644 src/kit/tx-ops.ts create mode 100644 src/kit/wallet-ops.ts create mode 100644 src/kit/webauthn-ops.ts create mode 100644 src/managers/credential-manager.ts create mode 100644 src/managers/index.ts create mode 100644 src/managers/signer-manager.test.ts create mode 100644 src/managers/signer-manager.ts create mode 100644 src/managers/submission-manager.ts create mode 100644 src/signers.test.ts create mode 100644 src/signers.ts diff --git a/src/index.ts b/src/index.ts index ec6b369..66fd087 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,13 +9,29 @@ */ // Core clients -export { PasskeyKit } from "./kit.js"; +export { + PasskeyKit, + type PasskeyKitConfig, + type CreateOptions, + type ConnectOptions, +} from "./kit.js"; export { PasskeyServer } from "./server.js"; export { SACClient } from "./sac.js"; // Generated contract client export { Client as PasskeyClient } from "passkey-kit-sdk"; +// Signer abstraction +export { + PasskeySigner, + Ed25519Signer, + PolicySigner, + type Signer, + type SignerContext, + type PreparedSignature, + type WebAuthnAuthenticator, +} from "./signers.js"; + // Signer-key helpers, indexer row, storage + result types export { SignerKey, diff --git a/src/kit.ts b/src/kit.ts index d9343e1..4b238c8 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -1,823 +1,454 @@ -import { Client as PasskeyClient, type Signature, type SignerKey as SDKSignerKey, type SignerLimits as SDKSignerLimits } from 'passkey-kit-sdk' -import * as StellarSdk from '@stellar/stellar-sdk' -import { StrKey, hash, xdr, Keypair, Address, TransactionBuilder, Operation } from '@stellar/stellar-sdk' -import { startRegistration, startAuthentication, type AuthenticationResponseJSON, type AuthenticatorAttestationResponseJSON, type AuthenticatorSelectionCriteria } from "@simplewebauthn/browser" -import { Buffer } from 'buffer' -import base64url from './base64url.js' -import type { SignerKey, SignerLimits, SignerStore } from './types.js' -import { PasskeyBase } from './base.js' -import { AssembledTransaction, basicNodeSigner, type AssembledTransactionOptions, type Spec as ContractSpec, type Tx } from '@stellar/stellar-sdk/contract' -import type { Server } from '@stellar/stellar-sdk/rpc' - -type P27HashIdPreimage = typeof xdr.HashIdPreimage & { - envelopeTypeSorobanAuthorizationWithAddress?: (value: unknown) => xdr.HashIdPreimage +/** + * `PasskeyKit` — the browser-side facade for creating and using smart-wallet + * accounts with WebAuthn passkeys. + * + * This is a ground-up rewrite of the old monolithic `kit.ts`. The signing + * pipeline, WebAuthn ceremonies, deploy path, and signer writes now live in + * dependency-injected managers ({@link CredentialManager}, {@link SignerManager}, + * {@link SubmissionManager}) wired here with late-bound closures. All Protocol-27 + * probe shims, dead commented experiments, and the legacy factory-address + * fallback are gone — the kit targets stellar-sdk >= 16 and the current wallet. + * + * @packageDocumentation + */ + +import { xdr } from "@stellar/stellar-sdk"; +import { Server } from "@stellar/stellar-sdk/rpc"; +import { + startAuthentication, + startRegistration, + type AuthenticationResponseJSON, + type AuthenticatorSelectionCriteria, +} from "@simplewebauthn/browser"; +import type { AssembledTransaction } from "@stellar/stellar-sdk/contract"; +import { Client as PasskeyClient } from "passkey-kit-sdk"; +import base64url from "./base64url.js"; +import { + SignerKey, + SignerStore, + type ConnectWalletResult, + type CreateWalletResult, + type SignerLimits, + type StorageAdapter, +} from "./types.js"; +import { + ConfigurationError, + PasskeyKitError, + PasskeyKitErrorCode, + WalletNotConnectedError, + WalletOwnershipError, +} from "./errors.js"; +import { PasskeyEventEmitter } from "./events.js"; +import { DEFAULT_TIMEOUT_SECONDS } from "./constants.js"; +import { PasskeySigner, type Signer, type SignerContext } from "./signers.js"; +import type { WebAuthnClient } from "./kit/webauthn-ops.js"; +import type { CreatedPasskey } from "./kit/webauthn-ops.js"; +import type { SignOptions } from "./kit/tx-ops.js"; +import { calculateExpiration } from "./kit/tx-ops.js"; +import { + CredentialManager, + SignerManager, + SubmissionManager, +} from "./managers/index.js"; +import { resolveDeployer } from "./kit/deploy-ops.js"; + +/** Configuration for a {@link PasskeyKit} client. */ +export interface PasskeyKitConfig { + /** Stellar RPC URL. */ + rpcUrl: string; + /** Network passphrase. */ + networkPassphrase: string; + /** Smart-wallet WASM hash (hex) used to deploy new wallets. */ + walletWasmHash: string; + /** WebAuthn Relying Party id (domain); defaults to the current origin. */ + rpId?: string; + /** + * Secret key (`S…`) for the fee-paying deployer. Defaults to the canonical + * deterministic deployer (see {@link resolveDeployer}); overriding it changes + * derived wallet addresses. + */ + deploySource?: string; + /** Transaction timeout, in seconds (default 30). */ + timeoutInSeconds?: number; + /** Optional passkey-record storage adapter (see `passkey-kit/storage`). */ + storage?: StorageAdapter; + /** Custom WebAuthn implementation (for testing). */ + WebAuthn?: WebAuthnClient; } -type P27Xdr = typeof xdr & { - HashIdPreimageSorobanAuthorizationWithAddress?: new (fields: { - networkId: Buffer - nonce: xdr.Int64 - signatureExpirationLedger: number - invocation: xdr.SorobanAuthorizedInvocation - address: xdr.ScAddress - }) => unknown +/** Options for {@link PasskeyKit.createKey}/{@link PasskeyKit.createWallet}. */ +export interface CreateOptions { + authenticatorSelection?: AuthenticatorSelectionCriteria; } -type P27AuthSdk = typeof StellarSdk & { - buildAuthorizationEntryPreimage?: ( - entry: xdr.SorobanAuthorizationEntry, - validUntilLedgerSeq: number, - networkPassphrase: string - ) => xdr.HashIdPreimage +/** Options for {@link PasskeyKit.connectWallet}. */ +export interface ConnectOptions { + /** A specific keyId to connect (skips the discovery ceremony). */ + keyId?: string | Uint8Array; + /** Indexer-backed keyId → contract lookup, used when derivation misses. */ + getContractId?: (keyId: string) => Promise; + /** + * Also assert the wallet's on-chain WASM hash equals `walletWasmHash`. + * Off by default: an upgraded wallet legitimately runs a different hash. + */ + verifyWasmHash?: boolean; } -type PasskeyClientWithSpec = PasskeyClient & { - spec: ContractSpec -} - -export function getAddressCredentials(credentials: xdr.SorobanCredentials): xdr.SorobanAddressCredentials { - const typedCredentials = credentials as unknown as { - address: () => xdr.SorobanAddressCredentials - addressV2?: () => xdr.SorobanAddressCredentials - addressWithDelegates?: () => { - addressCredentials: () => xdr.SorobanAddressCredentials - } - } - - switch (credentials.switch().name as string) { - case 'sorobanCredentialsAddress': - return typedCredentials.address() - case 'sorobanCredentialsAddressV2': - if (!typedCredentials.addressV2) { - throw new Error( - 'ADDRESS_V2 credentials require an SDK with Protocol 27 credential support' - ) - } - return typedCredentials.addressV2() - case 'sorobanCredentialsAddressWithDelegates': - if (!typedCredentials.addressWithDelegates) { - throw new Error( - 'ADDRESS_WITH_DELEGATES credentials require an SDK with Protocol 27 credential support' - ) - } - return typedCredentials.addressWithDelegates().addressCredentials() - } - - throw new Error(`Soroban credentials do not contain address credentials: ${credentials.switch().name}`) -} - -export function usesAddressBoundPayload(credentials: xdr.SorobanCredentials): boolean { - const credentialType = credentials.switch().name as string - return credentialType === 'sorobanCredentialsAddressV2' - || credentialType === 'sorobanCredentialsAddressWithDelegates' -} - -export function assertSignatureExpirationLedger(expiration: number): void { - if (!Number.isInteger(expiration) || expiration < 0 || expiration > 0xffffffff) { - throw new Error('Soroban signature expiration ledger must be a uint32 integer') - } -} - -export function buildSignaturePayload( - networkPassphrase: string, - entry: xdr.SorobanAuthorizationEntry, - expiration: number -): Buffer { - assertSignatureExpirationLedger(expiration) - - const credentials = getAddressCredentials(entry.credentials()) - const sdkAuth = StellarSdk as P27AuthSdk - - if (sdkAuth.buildAuthorizationEntryPreimage) { - credentials.signatureExpirationLedger(expiration) - return hash(sdkAuth.buildAuthorizationEntryPreimage( - entry, - expiration, - networkPassphrase - ).toXDR()) - } - - if (usesAddressBoundPayload(entry.credentials())) { - const hashIdPreimage = xdr.HashIdPreimage as P27HashIdPreimage - const WithAddressPreimage = (xdr as P27Xdr).HashIdPreimageSorobanAuthorizationWithAddress - - if (!hashIdPreimage.envelopeTypeSorobanAuthorizationWithAddress || !WithAddressPreimage) { - throw new Error( - 'Address-bound Soroban auth credentials require an SDK with Protocol 27 auth preimage support' - ) - } - - credentials.signatureExpirationLedger(expiration) - const preimage = hashIdPreimage.envelopeTypeSorobanAuthorizationWithAddress( - new WithAddressPreimage({ - networkId: hash(Buffer.from(networkPassphrase)), - nonce: credentials.nonce(), - signatureExpirationLedger: expiration, - invocation: entry.rootInvocation(), - address: credentials.address(), - }) - ) - - return hash(preimage.toXDR()) +export class PasskeyKit { + readonly rpc: Server; + readonly rpcUrl: string; + readonly networkPassphrase: string; + readonly walletWasmHash: string; + readonly rpId?: string; + + /** Lifecycle event emitter (walletCreated, walletConnected, …). */ + readonly events = new PasskeyEventEmitter(); + + private readonly timeoutInSeconds: number; + private readonly webAuthn: WebAuthnClient; + + private readonly credentialManager: CredentialManager; + private readonly signerManager: SignerManager; + private readonly submissionManager: SubmissionManager; + + /** The connected passkey's base64url keyId, if any. */ + keyId: string | undefined; + /** The connected wallet client, if any. */ + wallet: PasskeyClient | undefined; + + constructor(config: PasskeyKitConfig) { + if (!config.rpcUrl) { + throw new ConfigurationError( + "rpcUrl is required", + PasskeyKitErrorCode.MISSING_CONFIG + ); } - - credentials.signatureExpirationLedger(expiration) - const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( - new xdr.HashIdPreimageSorobanAuthorization({ - networkId: hash(Buffer.from(networkPassphrase)), - nonce: credentials.nonce(), - signatureExpirationLedger: expiration, - invocation: entry.rootInvocation() - }) - ) - - return hash(preimage.toXDR()) -} - -// TODO we should allow setting the rpId in the constructor so we don't have to keep setting it for every call -// e.g. if you want to sign with a smol.xyz key on next.smol.xyz key you have to set rpId over and over again - -export class PasskeyKit extends PasskeyBase { - declare rpc: Server - declare rpcUrl: string - - private walletKeypair: Keypair - private walletPublicKey: string - private walletWasmHash: string - private timeoutInSeconds: number - private WebAuthn: { - startRegistration: typeof startRegistration, - startAuthentication: typeof startAuthentication + if (!config.networkPassphrase) { + throw new ConfigurationError( + "networkPassphrase is required", + PasskeyKitErrorCode.MISSING_CONFIG + ); } - - public keyId: string | undefined - public networkPassphrase: string - public wallet: PasskeyClient | undefined - - private get walletSpec(): ContractSpec { - return (this.wallet as PasskeyClientWithSpec).spec + if (!config.walletWasmHash) { + throw new ConfigurationError( + "walletWasmHash is required", + PasskeyKitErrorCode.MISSING_CONFIG + ); } - constructor(options: { - rpcUrl: string, - networkPassphrase: string, - walletWasmHash: string, - timeoutInSeconds?: number, - WebAuthn?: { - startRegistration: typeof startRegistration, - startAuthentication: typeof startAuthentication - } - }) { - const { rpcUrl, networkPassphrase, walletWasmHash, WebAuthn } = options - - super(rpcUrl) - - this.networkPassphrase = networkPassphrase - // this account exists as the seed source for deploying new wallets - // not using the genesis wallet as on mainnet the account has no usable signers - // there's a chance this isn't the best move and should instead be a constructor variable - // alternatively when we create a new wallet we shouldn't inherit the source as the auth entry signer - // Keypair.fromRawEd25519Seed(hash(Buffer.from(this.networkPassphrase))) - this.walletKeypair = Keypair.fromRawEd25519Seed(hash(Buffer.from('kalepail'))); - this.walletPublicKey = this.walletKeypair.publicKey() - this.walletWasmHash = walletWasmHash - this.timeoutInSeconds = options.timeoutInSeconds || 30 // OpenZeppelin Relayer requires <= 30 second timeout so let's default to that - this.WebAuthn = WebAuthn || { startRegistration, startAuthentication } + this.rpc = new Server(config.rpcUrl); + this.rpcUrl = config.rpcUrl; + this.networkPassphrase = config.networkPassphrase; + this.walletWasmHash = config.walletWasmHash; + this.rpId = config.rpId; + this.timeoutInSeconds = config.timeoutInSeconds ?? DEFAULT_TIMEOUT_SECONDS; + this.webAuthn = + config.WebAuthn ?? ({ startRegistration, startAuthentication } as WebAuthnClient); + + const deployerKeypair = resolveDeployer(config.deploySource); + + this.credentialManager = new CredentialManager({ + rpId: this.rpId, + webAuthn: this.webAuthn, + storage: config.storage, + }); + + this.signerManager = new SignerManager({ + networkPassphrase: this.networkPassphrase, + timeoutInSeconds: this.timeoutInSeconds, + rpc: this.rpc, + getWallet: () => this.wallet, + getContractId: () => this.wallet?.options.contractId, + getSignerContext: () => this.signerContext(), + calculateExpiration: () => + calculateExpiration({ rpc: this.rpc, timeoutInSeconds: this.timeoutInSeconds }), + }); + + this.submissionManager = new SubmissionManager({ + rpc: this.rpc, + rpcUrl: config.rpcUrl, + networkPassphrase: this.networkPassphrase, + walletWasmHash: this.walletWasmHash, + deployerKeypair, + timeoutInSeconds: this.timeoutInSeconds, + }); + } + + /** The connected wallet's contract id, if any. */ + get contractId(): string | undefined { + return this.wallet?.options.contractId; + } + + /** The fee-paying deployer's `G…` public key. */ + get deployerPublicKey(): string { + return this.submissionManager.deployerPublicKey; + } + + private signerContext(): SignerContext { + return { rpId: this.rpId, webAuthn: this.webAuthn, defaultKeyId: this.keyId }; + } + + // -- Passkey / wallet lifecycle --------------------------------------------- + + /** Run a passkey registration ceremony without deploying a wallet. */ + createKey( + appName: string, + userName: string, + options?: CreateOptions + ): Promise { + return this.credentialManager.createKey( + appName, + userName, + options?.authenticatorSelection + ); + } + + /** + * Register a passkey and deploy a smart wallet initialized with it as the + * first signer. Returns the signed deploy transaction (submit it via + * `PasskeyServer`). + */ + async createWallet( + appName: string, + userName: string, + options?: CreateOptions + ): Promise { + const created = await this.createKey(appName, userName, options); + + const deployTx = await this.submissionManager.buildDeployTransaction( + created.keyIdBuffer, + created.publicKey + ); + const contractId = deployTx.result.options.contractId; + + this.wallet = new PasskeyClient({ + contractId, + rpcUrl: this.rpcUrl, + networkPassphrase: this.networkPassphrase, + }); + this.keyId = created.keyId; + + const signedTx = await this.submissionManager.signDeploy(deployTx); + + await this.credentialManager.rememberPasskey({ + keyId: created.keyId, + publicKey: created.publicKey, + contractId, + createdAt: Date.now(), + }); + + this.events.emit("walletCreated", { contractId, keyId: created.keyId }); + + return { + rawResponse: created.rawResponse, + keyId: created.keyIdBuffer, + keyIdBase64: created.keyId, + contractId, + signedTx, + }; + } + + /** + * Connect an existing wallet from a passkey. + * + * Resolves the wallet address by (1) deterministic derivation from the keyId, + * (2) local storage, then (3) an injected indexer lookup — and then VERIFIES + * ownership: the keyId must resolve to a live signer on the wallet (#601 F7). + * This closes the unverified reverse-lookup hole (#598 F3) at the SDK layer. + * + * @throws {WalletOwnershipError} If the keyId is not a signer on the wallet. + */ + async connectWallet(options?: ConnectOptions): Promise { + let rawResponse: AuthenticationResponseJSON | undefined; + let keyId = options?.keyId; + + if (!keyId) { + const auth = await this.credentialManager.authenticate(); + keyId = auth.keyId; + rawResponse = auth.rawResponse; } - public async createWallet( - app: string, - user: string, - settings?: { - rpId?: string - authenticatorSelection?: AuthenticatorSelectionCriteria - } - ) { - const { rawResponse, keyId, keyIdBase64, publicKey } = await this.createKey(app, user, settings) - - const at = await PasskeyClient.deploy( - { - signer: { - tag: 'Secp256r1', - values: [ - keyId, - publicKey, - [undefined], - [undefined], - { tag: 'Persistent', values: undefined }, - ] - } - }, - { - rpcUrl: this.rpcUrl, - wasmHash: this.walletWasmHash, - networkPassphrase: this.networkPassphrase, - publicKey: this.walletPublicKey, - salt: hash(keyId), - timeoutInSeconds: this.timeoutInSeconds, - } - ) - - const contractId = at.result.options.contractId - - this.wallet = new PasskeyClient({ - contractId, - networkPassphrase: this.networkPassphrase, - rpcUrl: this.rpcUrl - }) - - await at.sign({ - signTransaction: basicNodeSigner(this.walletKeypair, this.networkPassphrase).signTransaction - }) - - return { - rawResponse, - keyId, - keyIdBase64, - contractId, - signedTx: at.signed! - } + const keyIdBase64 = + keyId instanceof Uint8Array ? base64url(Buffer.from(keyId)) : keyId; + const keyIdBuffer = + keyId instanceof Uint8Array ? Buffer.from(keyId) : base64url.toBuffer(keyId); + + // 1) Deterministic derivation, confirmed by an on-chain instance read. + let contractId: string | undefined = + this.submissionManager.deriveWalletAddress(keyIdBuffer); + try { + await this.rpc.getContractData( + contractId, + xdr.ScVal.scvLedgerKeyContractInstance() + ); + } catch { + // 2) local storage, then 3) injected indexer lookup. + contractId = + (await this.credentialManager.lookupContractId(keyIdBase64)) ?? + (options?.getContractId + ? await options.getContractId(keyIdBase64) + : undefined); } - public async createKey(app: string, user: string, settings?: { - rpId?: string - authenticatorSelection?: AuthenticatorSelectionCriteria - }) { - const now = new Date() - const displayName = `${user} — ${now.toLocaleString()}` - const { rpId, authenticatorSelection = { - residentKey: "preferred", - userVerification: "preferred", - } } = settings || {} - - // TODO discover the contract id before creating the key so we can use it in the key name - // TODO it's possible for the creation to fail in which case we've created a passkey but it's not onchain. - // In this case we should save the passkey info and retry uploading it async vs asking the user to create another passkey - // This does introduce a storage dependency though so it likely needs to be a function with some logic for choosing how to store the passkey data - - const rawResponse = await this.WebAuthn.startRegistration({ - optionsJSON: { - challenge: base64url("stellaristhebetterblockchain"), - rp: { - id: rpId, - name: app, - }, - user: { - id: base64url(`${user}:${now.getTime()}:${Math.random()}`), - name: displayName, - displayName - }, - authenticatorSelection, - pubKeyCredParams: [{ alg: -7, type: "public-key" }], - } - }); - const { id, response } = rawResponse - - if (!this.keyId) - this.keyId = id; - - return { - rawResponse, - keyId: base64url.toBuffer(id), - keyIdBase64: id, - publicKey: await this.getPublicKey(response), - } + if (!contractId) { + throw new PasskeyKitError( + "Could not resolve a wallet for the given passkey", + PasskeyKitErrorCode.WALLET_NOT_FOUND, + { context: { keyId: keyIdBase64 } } + ); } - public async connectWallet(opts?: { - rpId?: string, - keyId?: string | Uint8Array, - getContractId?: (keyId: string) => Promise, - // TEMP for backwards compatibility for when we seeded wallets from a factory address - // Consider putting this somewhere else?? - walletPublicKey?: string - }) { - let { rpId, keyId, getContractId, walletPublicKey } = opts || {} - let keyIdBuffer: Buffer - let rawResponse: AuthenticationResponseJSON | undefined; - - if (!keyId) { - rawResponse = await this.WebAuthn.startAuthentication({ - optionsJSON: { - challenge: base64url("stellaristhebetterblockchain"), - rpId, - userVerification: "preferred", - } - }); - - keyId = rawResponse.id - } - - if (keyId instanceof Uint8Array) { - keyIdBuffer = Buffer.from(keyId) - keyId = base64url(keyIdBuffer) - } else { - keyIdBuffer = base64url.toBuffer(keyId) - } - - if (!this.keyId) - this.keyId = keyId - - // Check for the contractId on-chain as a derivation from the keyId. This is the easiest and "cheapest" check however it will only work for the initially deployed passkey if it was used as derivation - let contractId: string | undefined = this.encodeContract(this.walletPublicKey, keyIdBuffer); - - // attempt passkey id derivation - try { - // TODO what is the error if the entry exists but is archived? - await this.rpc.getContractData(contractId, xdr.ScVal.scvLedgerKeyContractInstance()) - } - // if that fails look up from the `getContractId` function - catch { - contractId = getContractId && await getContractId(keyId) - } - - //// - // TEMP for backwards compatibility for when we seeded wallets from a factory address - // Consider putting this in the constructor - if (!contractId && walletPublicKey) { - contractId = this.encodeContract(walletPublicKey, keyIdBuffer); - - try { - await this.rpc.getContractData(contractId, xdr.ScVal.scvLedgerKeyContractInstance()) - } catch { - contractId = undefined - } - } - //// - - if (!contractId) - throw new Error('Failed to connect wallet') - - this.wallet = new PasskeyClient({ - contractId, - rpcUrl: this.rpcUrl, - networkPassphrase: this.networkPassphrase, - }) - - return { - rawResponse, - keyId: keyIdBuffer, - keyIdBase64: keyId, - contractId - } + this.wallet = new PasskeyClient({ + contractId, + rpcUrl: this.rpcUrl, + networkPassphrase: this.networkPassphrase, + }); + this.keyId = keyIdBase64; + + // 3) Ownership verification (F7): the keyId must be a live signer. + const signerVal = await this.signerManager.getSigner( + SignerKey.Secp256r1(keyIdBase64) + ); + if (!signerVal) { + this.wallet = undefined; + this.keyId = undefined; + throw new WalletOwnershipError( + "The passkey is not a signer on the resolved wallet", + { contractId, keyId: keyIdBase64 } + ); } - public async signAuthEntry( - entry: xdr.SorobanAuthorizationEntry, - options?: { - rpId?: string, - keyId?: 'any' | string | Uint8Array - keypair?: Keypair, - policy?: string, - expiration?: number - } - ) { - let { rpId, keyId, keypair, policy, expiration } = options || {} - - if ([keyId, keypair, policy].filter((arg) => !!arg).length > 1) - throw new Error('Exactly one of `options.keyId`, `options.keypair`, or `options.policy` must be provided.'); - - const credentialType = entry.credentials().switch().name as string - if (credentialType === 'sorobanCredentialsAddressWithDelegates') { - throw new Error('ADDRESS_WITH_DELEGATES auth entries are not supported by passkey signing yet') - } - - const credentials = getAddressCredentials(entry.credentials()); - - if (!expiration) { - expiration = credentials.signatureExpirationLedger() - - if (!expiration) { - const { sequence } = await this.rpc.getLatestLedger() - expiration = sequence + this.timeoutInSeconds / 5; // assumes 5 second ledger time - expiration = Math.ceil(expiration) - } - } - - const payload = buildSignaturePayload(this.networkPassphrase, entry, expiration) - - // let signatures: Signatures - let key: SDKSignerKey - let val: Signature | undefined - - // const scSpecTypeDefSignatures = xdr.ScSpecTypeDef.scSpecTypeUdt( - // new xdr.ScSpecTypeUdt({ name: "Signatures" }), - // ); - - // switch (credentials.signature().switch()) { - // case xdr.ScValType.scvVoid(): - // signatures = [new Map()] - // break; - // default: { - // signatures = this.wallet!.spec.scValToNative(credentials.signature(), scSpecTypeDefSignatures) - // } - // } - - // Sign with a policy - if (policy) { - key = { - tag: "Policy", - values: [policy] - } - val = { - tag: "Policy", - values: undefined, - } - } - - // Sign with the keypair as an ed25519 signer - else if (keypair) { - const signature = keypair.sign(payload); - - key = { - tag: "Ed25519", - values: [keypair.rawPublicKey()] - } - val = { - tag: "Ed25519", - values: [signature], - } - } - - // Default, use passkey - else { - const authenticationResponse = await this.WebAuthn.startAuthentication({ - optionsJSON: keyId === 'any' - || (!keyId && !this.keyId) - ? { - challenge: base64url(payload), - rpId, - userVerification: "preferred", - } - : { - challenge: base64url(payload), - rpId, - allowCredentials: [ - { - id: keyId instanceof Uint8Array - ? base64url(Buffer.from(keyId)) - : keyId || this.keyId!, - type: "public-key", - }, - ], - userVerification: "preferred", - } - }); - - key = { - tag: "Secp256r1", - values: [base64url.toBuffer(authenticationResponse.id)] - } - val = { - tag: "Secp256r1", - values: [ - { - authenticator_data: base64url.toBuffer( - authenticationResponse.response.authenticatorData, - ), - client_data_json: base64url.toBuffer( - authenticationResponse.response.clientDataJSON, - ), - signature: this.compactSignature( - base64url.toBuffer(authenticationResponse.response.signature) - ), - }, - ], - } - } - - const scKeyType = xdr.ScSpecTypeDef.scSpecTypeUdt( - new xdr.ScSpecTypeUdt({ name: "SignerKey" }), - ); - const scValType = xdr.ScSpecTypeDef.scSpecTypeUdt( - new xdr.ScSpecTypeUdt({ name: "Signature" }), - ); - const scKey = this.walletSpec.nativeToScVal(key, scKeyType); - const scVal = val ? this.walletSpec.nativeToScVal(val, scValType) : xdr.ScVal.scvVoid(); - const scEntry = new xdr.ScMapEntry({ - key: scKey, - val: scVal, - }) - - switch (credentials.signature().switch().name) { - case 'scvVoid': - credentials.signature(xdr.ScVal.scvVec([ - xdr.ScVal.scvMap([scEntry]) - ])) - break; - case 'scvVec': - // Add the new signature to the existing map - credentials.signature().vec()?.[0].map()?.push(scEntry) - - // Order the map by key - // Not using Buffer.compare because Symbols are 9 bytes and unused bytes _append_ 0s vs prepending them, which is too bad - credentials.signature().vec()?.[0].map()?.sort((a, b) => { - return ( - a.key().vec()![0].sym() + - a.key().vec()![1].toXDR().join('') - ).localeCompare( - b.key().vec()![0].sym() + - b.key().vec()![1].toXDR().join('') - ) - }) - break; - default: - throw new Error('Unsupported signature') - } - - // Insert the new signature into the signatures Map - // signatures[0].set(key, val) - - // Insert the new signatures Map into the credentials - // credentials.signature( - // this.wallet!.spec.nativeToScVal(signatures, scSpecTypeDefSignatures) - // ) - - // Order the signatures map - // credentials.signature().vec()?.[0].map()?.sort((a, b) => { - // return ( - // a.key().vec()![0].sym() + - // a.key().vec()![1].toXDR().join('') - // ).localeCompare( - // b.key().vec()![0].sym() + - // b.key().vec()![1].toXDR().join('') - // ) - // }) - - return entry + // 4) Optional wasm-hash check (opt-in; upgraded wallets differ by design). + if (options?.verifyWasmHash) { + await this.assertWalletWasmHash(contractId); } - public async sign( - txn: AssembledTransaction | Tx | string, - options?: { - rpId?: string, - keyId?: 'any' | string | Uint8Array - keypair?: Keypair, - policy?: string, - expiration?: number - } - ) { - if (!(txn instanceof AssembledTransaction)) { - try { - txn = AssembledTransaction.fromXDR(this.wallet!.options, typeof txn === 'string' ? txn : txn.toXDR(), this.walletSpec); - } catch { - if (!(txn instanceof AssembledTransaction)) { - const built = TransactionBuilder.fromXDR(typeof txn === 'string' ? txn : txn.toXDR(), this.networkPassphrase); - const operation = built.operations[0] as Operation.InvokeHostFunction; - - txn = await AssembledTransaction.buildWithOp( - Operation.invokeHostFunction({ func: operation.func }), - this.wallet!.options as AssembledTransactionOptions - ); - } - } - } - - await txn.signAuthEntries({ - address: this.wallet!.options.contractId, - authorizeEntry: (entry) => { - const clone = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR()) - return this.signAuthEntry(clone, options) - }, - }) - - return txn + this.events.emit("walletConnected", { contractId, keyId: keyIdBase64 }); + + return { rawResponse, keyId: keyIdBuffer, keyIdBase64, contractId }; + } + + private async assertWalletWasmHash(contractId: string): Promise { + const instance = await this.rpc.getContractData( + contractId, + xdr.ScVal.scvLedgerKeyContractInstance() + ); + const executable = instance.val + .contractData() + .val() + .instance() + .executable(); + if (executable.switch().name !== "contractExecutableWasm") { + throw new WalletOwnershipError("Wallet is not a WASM contract", { + contractId, + }); } - - public addSecp256r1(keyId: string | Uint8Array, publicKey: string | Uint8Array, limits: SignerLimits, store: SignerStore, expiration?: number) { - return this.secp256r1(keyId, publicKey, limits, store, 'add_signer', expiration) - } - public addEd25519(publicKey: string, limits: SignerLimits, store: SignerStore, expiration?: number) { - return this.ed25519(publicKey, limits, store, 'add_signer', expiration) + const wasmHash = executable.wasmHash().toString("hex"); + if (wasmHash !== this.walletWasmHash.toLowerCase()) { + throw new WalletOwnershipError("Wallet WASM hash does not match", { + contractId, + expected: this.walletWasmHash, + actual: wasmHash, + }); } - public addPolicy(policy: string, limits: SignerLimits, store: SignerStore, expiration?: number) { - return this.policy(policy, limits, store, 'add_signer', expiration) + } + + /** Disconnect the current wallet. */ + disconnect(): void { + const contractId = this.contractId; + this.wallet = undefined; + this.keyId = undefined; + if (contractId) { + this.events.emit("walletDisconnected", { contractId }); } + } - public updateSecp256r1(keyId: string | Uint8Array, publicKey: string | Uint8Array, limits: SignerLimits, store: SignerStore, expiration?: number) { - return this.secp256r1(keyId, publicKey, limits, store, 'update_signer', expiration) - } - public updateEd25519(publicKey: string, limits: SignerLimits, store: SignerStore, expiration?: number) { - return this.ed25519(publicKey, limits, store, 'update_signer', expiration) - } - public updatePolicy(policy: string, limits: SignerLimits, store: SignerStore, expiration?: number) { - return this.policy(policy, limits, store, 'update_signer', expiration) - } - - public remove(signer: SignerKey) { - return this.wallet!.remove_signer({ - signer_key: this.getSignerKey(signer) - }, { - timeoutInSeconds: this.timeoutInSeconds, - }); - } + // -- Signing ----------------------------------------------------------------- - private secp256r1(keyId: string | Uint8Array, publicKey: string | Uint8Array, limits: SignerLimits, store: SignerStore, fn: 'add_signer' | 'update_signer', expiration?: number) { - keyId = typeof keyId === 'string' ? base64url.toBuffer(keyId) : keyId - publicKey = typeof publicKey === 'string' ? base64url.toBuffer(publicKey) : publicKey - - return this.wallet![fn]({ - signer: { - tag: "Secp256r1", - values: [ - Buffer.from(keyId), - Buffer.from(publicKey), - [expiration], - this.getSignerLimits(limits), - { tag: store, values: undefined }, - ], - }, - }, { - timeoutInSeconds: this.timeoutInSeconds, - }); - } - private ed25519(publicKey: string, limits: SignerLimits, store: SignerStore, fn: 'add_signer' | 'update_signer', expiration?: number) { - return this.wallet![fn]({ - signer: { - tag: "Ed25519", - values: [ - Keypair.fromPublicKey(publicKey).rawPublicKey(), - [expiration], - this.getSignerLimits(limits), - { tag: store, values: undefined }, - ], - }, - }, { - timeoutInSeconds: this.timeoutInSeconds, - }); - } - private policy(policy: string, limits: SignerLimits, store: SignerStore, fn: 'add_signer' | 'update_signer', expiration?: number) { - return this.wallet![fn]({ - signer: { - tag: "Policy", - values: [ - policy, - [expiration], - this.getSignerLimits(limits), - { tag: store, values: undefined }, - ], - }, - }, { - timeoutInSeconds: this.timeoutInSeconds, - }); - } - - /* LATER - - Add a getKeyInfo action to get info about a specific passkey - Specifically looking for name, type, etc. data so a user could grok what signer mapped to what passkey - */ - - private getSignerLimits(limits: SignerLimits) { - if (!limits) - return [undefined] as SDKSignerLimits - - const sdk_limits: SDKSignerLimits = [new Map()] - - for (const [contract, signer_keys] of limits.entries()) { - let sdk_signer_keys: SDKSignerKey[] | undefined - - if (signer_keys?.length) { - sdk_signer_keys = [] - - for (const signer_key of signer_keys) { - sdk_signer_keys.push( - this.getSignerKey(signer_key) - ) - } - } - - sdk_limits[0]?.set(contract, sdk_signer_keys) - } - - return sdk_limits - } - - private getSignerKey({ key: tag, value }: SignerKey) { - let signer_key: SDKSignerKey - - switch (tag) { - case 'Policy': - signer_key = { - tag, - values: [value] - } - break; - case 'Ed25519': - signer_key = { - tag, - values: [Keypair.fromPublicKey(value).rawPublicKey()] - } - break; - case 'Secp256r1': - signer_key = { - tag, - values: [base64url.toBuffer(value)] - } - break; - } - - return signer_key - } - - private encodeContract(walletPublicKey: string, keyIdBuffer: Buffer) { - let contractId: string | undefined = StrKey.encodeContract(hash(xdr.HashIdPreimage.envelopeTypeContractId( - new xdr.HashIdPreimageContractId({ - networkId: hash(Buffer.from(this.networkPassphrase)), - contractIdPreimage: xdr.ContractIdPreimage.contractIdPreimageFromAddress( - new xdr.ContractIdPreimageFromAddress({ - address: Address.fromString(walletPublicKey).toScAddress(), - salt: hash(keyIdBuffer), - }) - ) - }) - ).toXDR())); - - return contractId - } - - private async getPublicKey(response: AuthenticatorAttestationResponseJSON) { - let publicKey: Buffer | undefined - - if (response.publicKey) { - publicKey = base64url.toBuffer(response.publicKey) - publicKey = publicKey?.slice(publicKey.length - 65) - } - - if ( - !publicKey - || publicKey[0] !== 0x04 - || publicKey.length !== 65 - ) { - let x: Buffer - let y: Buffer - - if (response.authenticatorData) { - const authenticatorData = base64url.toBuffer(response.authenticatorData) - const credentialIdLength = (authenticatorData[53] << 8) | authenticatorData[54] - - x = authenticatorData.slice(65 + credentialIdLength, 97 + credentialIdLength) - y = authenticatorData.slice(100 + credentialIdLength, 132 + credentialIdLength) - } else { - const attestationObject = base64url.toBuffer(response.attestationObject) - - let publicKeykPrefixSlice = Buffer.from([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20]) - let startIndex = attestationObject.indexOf(publicKeykPrefixSlice) - startIndex = startIndex + publicKeykPrefixSlice.length - - x = attestationObject.slice(startIndex, 32 + startIndex) - y = attestationObject.slice(35 + startIndex, 67 + startIndex) - } - - publicKey = Buffer.from([ - 0x04, // (0x04 prefix) https://en.bitcoin.it/wiki/Elliptic_Curve_Digital_Signature_Algorithm - ...x, - ...y - ]) - } - - /* TODO - - We're doing some pretty "smart" public key decoding stuff so we should verify the signature against this final public key before assuming it's safe to use and save on-chain - Hmm...Given that `startRegistration` doesn't produce a signature, verifying we've got the correct public key isn't really possible - - This probably needs to be an onchain check, even if just a simulation, just to ensure everything looks good before we get too far adding value etc. - */ - - return publicKey - } - - private compactSignature(signature: Buffer) { - // Decode the DER signature - let offset = 2; - - const rLength = signature[offset + 1]; - const r = signature.slice(offset + 2, offset + 2 + rLength); - - offset += 2 + rLength; - - const sLength = signature[offset + 1]; - const s = signature.slice(offset + 2, offset + 2 + sLength); - - // Convert r and s to BigInt - const rBigInt = BigInt('0x' + r.toString('hex')); - let sBigInt = BigInt('0x' + s.toString('hex')); - - // Ensure s is in the low-S form - // https://github.com/stellar/stellar-protocol/discussions/1435#discussioncomment-8809175 - // https://discord.com/channels/897514728459468821/1233048618571927693 - // Define the order of the curve secp256r1 - // https://github.com/RustCrypto/elliptic-curves/blob/master/p256/src/lib.rs#L72 - const n = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551'); - const halfN = n / 2n; - - if (sBigInt > halfN) - sBigInt = n - sBigInt; - - // Convert back to buffers and ensure they are 32 bytes - const rPadded = Buffer.from(rBigInt.toString(16).padStart(64, '0'), 'hex'); - const sLowS = Buffer.from(sBigInt.toString(16).padStart(64, '0'), 'hex'); - - // Concatenate r and low-s - const concatSignature = Buffer.concat([rPadded, sLowS]); - - return concatSignature; + /** Sign a single auth entry (defaults to the connected passkey signer). */ + signAuthEntry( + entry: xdr.SorobanAuthorizationEntry, + signer?: Signer, + options?: SignOptions + ): Promise { + return this.signerManager.signAuthEntry(entry, signer, options); + } + + /** Sign an assembled transaction's wallet auth entries. */ + sign( + txn: AssembledTransaction, + signer?: Signer, + options?: SignOptions + ): Promise> { + return this.signerManager.sign(txn, signer, options); + } + + // -- Signer management ------------------------------------------------------- + + addSecp256r1( + keyId: string | Uint8Array, + publicKey: string | Uint8Array, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return this.signerManager.addSecp256r1(keyId, publicKey, limits, store, expiration); + } + updateSecp256r1( + keyId: string | Uint8Array, + publicKey: string | Uint8Array, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return this.signerManager.updateSecp256r1(keyId, publicKey, limits, store, expiration); + } + addEd25519( + publicKey: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return this.signerManager.addEd25519(publicKey, limits, store, expiration); + } + updateEd25519( + publicKey: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return this.signerManager.updateEd25519(publicKey, limits, store, expiration); + } + addPolicy( + policy: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return this.signerManager.addPolicy(policy, limits, store, expiration); + } + updatePolicy( + policy: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return this.signerManager.updatePolicy(policy, limits, store, expiration); + } + remove(signerKey: SignerKey): Promise> { + return this.signerManager.remove(signerKey); + } + + /** Read a signer entry from the ledger (temporary before persistent). */ + getSigner(signerKey: SignerKey) { + return this.signerManager.getSigner(signerKey); + } + + /** Require a connected wallet, or throw. */ + requireWallet(): PasskeyClient { + if (!this.wallet) { + throw new WalletNotConnectedError(); } + return this.wallet; + } } diff --git a/src/kit/auth-payload.ts b/src/kit/auth-payload.ts index 5768da0..659a502 100644 --- a/src/kit/auth-payload.ts +++ b/src/kit/auth-payload.ts @@ -1,14 +1,16 @@ /** - * Soroban authorization-entry payload helpers. + * Soroban authorization-entry payload helpers + the flat `Signatures` map codec. * * These are thin wrappers over the stellar-sdk's native Protocol-27 auth * primitives (`buildAuthorizationEntryPreimage`). The old kit hand-rolled a * stack of "does this SDK support P27?" probe shims; on stellar-sdk >= 16 those * are all dead, so this module simply uses the SDK. * - * This is the foundational slice (B1). The full signing pipeline — the - * host-ordered `compareScVal` Signatures-map merge and Udt encoding — is layered - * on top in B2. + * The smart wallet's `__check_auth` reads a `Signatures` value — a single + * `Map` — from the address credentials. Each signer + * contributes one entry keyed by its on-chain `SignerKey`. The host rejects an + * `ScMap` whose keys are not in the host's canonical sort order, so the map is + * sorted with {@link compareScVal} (NOT a byte/`localeCompare` sort — see below). * * @packageDocumentation */ @@ -18,8 +20,17 @@ import { hash, xdr, } from "@stellar/stellar-sdk"; +import type { Spec as ContractSpec } from "@stellar/stellar-sdk/contract"; +import type { + Signature as SDKSignature, + SignerKey as SDKSignerKey, +} from "passkey-kit-sdk"; import { SigningError, PasskeyKitErrorCode } from "../errors.js"; +// ============================================================================ +// Address credentials + signature payload +// ============================================================================ + /** * Extract the {@link xdr.SorobanAddressCredentials} from a credentials union, * for the address / addressV2 / addressWithDelegates variants. @@ -105,3 +116,165 @@ export function buildSignaturePayload( ).toXDR() ); } + +// ============================================================================ +// Host-order ScVal comparison +// ============================================================================ + +/** + * Compare two ScVals in the order the Soroban host uses when it validates that + * an ScMap is sorted by key (host `metered_map::from_map`, which rejects an + * unsorted map with `InvalidInput`). + * + * This is deliberately NOT a comparison of the XDR byte encodings. XDR prefixes + * every variable-length payload (Bytes, Symbol, String, Vec) with a 4-byte + * length, so `toXDR("hex")` sorting is length-major; the host instead compares + * those payloads element-wise by content (Rust slice `Ord`), where a shorter + * value that is a prefix of a longer one sorts first. The old kit's + * `localeCompare` on a symbol+hex string approximated this and could diverge + * from the host, producing a map the wallet rejects at `__check_auth`. + * + * Only the shapes that appear as `SignerKey` map keys are handled precisely + * (Vec, Symbol, String, Bytes, Address). All other shapes are same-type here + * (the discriminant is compared first), so their fixed-width XDR encodings sort + * order-equivalently to the host. + */ +export function compareScVal(a: xdr.ScVal, b: xdr.ScVal): number { + const aType = a.switch().value; + const bType = b.switch().value; + if (aType !== bType) { + return aType < bType ? -1 : 1; + } + + switch (a.switch().name) { + case "scvVec": { + const av = a.vec() ?? []; + const bv = b.vec() ?? []; + const len = Math.min(av.length, bv.length); + for (let i = 0; i < len; i += 1) { + const cmp = compareScVal(av[i]!, bv[i]!); + if (cmp !== 0) return cmp; + } + return av.length - bv.length; + } + case "scvMap": { + const am = a.map() ?? []; + const bm = b.map() ?? []; + const len = Math.min(am.length, bm.length); + for (let i = 0; i < len; i += 1) { + const keyCmp = compareScVal(am[i]!.key(), bm[i]!.key()); + if (keyCmp !== 0) return keyCmp; + const valCmp = compareScVal(am[i]!.val(), bm[i]!.val()); + if (valCmp !== 0) return valCmp; + } + return am.length - bm.length; + } + case "scvSymbol": + return Buffer.compare( + Buffer.from(a.sym().toString(), "utf8"), + Buffer.from(b.sym().toString(), "utf8") + ); + case "scvString": + return Buffer.compare( + Buffer.from(a.str().toString(), "utf8"), + Buffer.from(b.str().toString(), "utf8") + ); + case "scvBytes": + return Buffer.compare(Buffer.from(a.bytes()), Buffer.from(b.bytes())); + case "scvAddress": + // ScAddress is a type discriminant plus a fixed-width key, so a byte + // comparison of the encoding matches the host's (type, key) ordering. + return Buffer.compare(a.address().toXDR(), b.address().toXDR()); + default: + // Same-type scalar (or any unhandled shape): its XDR encoding is + // fixed-width for a given type, so a byte comparison is order-equivalent. + return Buffer.compare(a.toXDR(), b.toXDR()); + } +} + +// ============================================================================ +// SignerKey / Signature encoding + the flat Signatures map +// ============================================================================ + +const SIGNER_KEY_UDT = xdr.ScSpecTypeDef.scSpecTypeUdt( + new xdr.ScSpecTypeUdt({ name: "SignerKey" }) +); +const SIGNATURE_UDT = xdr.ScSpecTypeDef.scSpecTypeUdt( + new xdr.ScSpecTypeUdt({ name: "Signature" }) +); + +/** The `SignerVal` UDT type def, for decoding a stored signer entry's value. */ +export const SIGNER_VAL_UDT = xdr.ScSpecTypeDef.scSpecTypeUdt( + new xdr.ScSpecTypeUdt({ name: "SignerVal" }) +); + +/** Encode a native `SignerKey` union into its contract ScVal via the spec. */ +export function signerKeyToScVal( + spec: ContractSpec, + key: SDKSignerKey +): xdr.ScVal { + return spec.nativeToScVal(key, SIGNER_KEY_UDT); +} + +/** + * Encode a native `Signature` union into its contract ScVal via the spec, or + * `scvVoid` when there is no signature value (policy signers). + */ +export function signatureToScVal( + spec: ContractSpec, + val: SDKSignature | undefined +): xdr.ScVal { + return val ? spec.nativeToScVal(val, SIGNATURE_UDT) : xdr.ScVal.scvVoid(); +} + +/** + * Upsert a `(SignerKey, Signature)` pair into the entry's `Signatures` value, + * which is encoded as `scvVec([ scvMap([...]) ])`. + * + * A signer key already present in the map is replaced (co-signing is idempotent + * per signer). The map is re-sorted by key in host order after every insert, so + * the wallet's `__check_auth` accepts it. + * + * @throws {SigningError} If the existing signature is neither void nor a + * Signatures vec. + */ +export function upsertSignatureEntry( + credentials: xdr.SorobanAddressCredentials, + scKey: xdr.ScVal, + scVal: xdr.ScVal +): void { + const newEntry = new xdr.ScMapEntry({ key: scKey, val: scVal }); + + switch (credentials.signature().switch().name) { + case "scvVoid": { + credentials.signature(xdr.ScVal.scvVec([xdr.ScVal.scvMap([newEntry])])); + break; + } + case "scvVec": { + const map = credentials.signature().vec()?.[0]?.map(); + if (!map) { + throw new SigningError( + "Malformed Signatures value: expected a map inside the signatures vector", + PasskeyKitErrorCode.SIGNING_FAILED + ); + } + const existingIndex = map.findIndex( + (entry) => compareScVal(entry.key(), scKey) === 0 + ); + if (existingIndex >= 0) { + map[existingIndex] = newEntry; + } else { + map.push(newEntry); + } + // Host order: the wallet converts this map to a host object and rejects + // it if the keys are not in host-sort order. + map.sort((x, y) => compareScVal(x.key(), y.key())); + break; + } + default: + throw new SigningError( + `Unsupported existing signature value: ${credentials.signature().switch().name}`, + PasskeyKitErrorCode.SIGNING_FAILED + ); + } +} diff --git a/src/kit/deploy-ops.ts b/src/kit/deploy-ops.ts new file mode 100644 index 0000000..295f22c --- /dev/null +++ b/src/kit/deploy-ops.ts @@ -0,0 +1,95 @@ +/** + * Smart-wallet deployment operations. + * + * The deployer keypair is now configurable (`config.deploySource`) instead of a + * hard-coded `Keypair.fromRawEd25519Seed(hash("kalepail"))`. The default still + * derives from {@link DEFAULT_DEPLOYER_SEED} = `"kalepail"` so contract-id + * determinism (and indexer reverse-lookup) is preserved out of the box. + * + * @packageDocumentation + */ + +import { Keypair, hash } from "@stellar/stellar-sdk"; +import type { AssembledTransaction } from "@stellar/stellar-sdk/contract"; +import { + Client as PasskeyClient, + type Signer as SDKSigner, +} from "passkey-kit-sdk"; +import { DEFAULT_DEPLOYER_SEED } from "../constants.js"; +import { deriveContractAddress } from "../utils.js"; +import { ConfigurationError, PasskeyKitErrorCode } from "../errors.js"; + +/** + * Resolve the fee-paying deployer keypair from an optional secret. + * + * With no secret, derives the canonical deterministic deployer from + * {@link DEFAULT_DEPLOYER_SEED}. The deployer only pays fees and salts the + * deploy; it never controls the wallet. Supplying a different `deploySource` + * changes the derived contract addresses (and breaks keyId → contract + * discovery), so this is documented as an advanced option. + * + * @throws {ConfigurationError} If `deploySource` is not a valid secret key. + */ +export function resolveDeployer(deploySource?: string): Keypair { + if (deploySource) { + try { + return Keypair.fromSecret(deploySource); + } catch { + throw new ConfigurationError( + "deploySource must be a valid Stellar secret key (S…)", + PasskeyKitErrorCode.INVALID_CONFIG + ); + } + } + return Keypair.fromRawEd25519Seed(hash(Buffer.from(DEFAULT_DEPLOYER_SEED))); +} + +/** Deterministically derive a smart-wallet address for a passkey credential. */ +export function deriveWalletAddress( + deps: { networkPassphrase: string; deployerPublicKey: string }, + keyId: Buffer +): string { + return deriveContractAddress(keyId, deps.deployerPublicKey, deps.networkPassphrase); +} + +/** + * Build the smart-wallet deploy transaction, initializing it with the passkey as + * the first (unlimited, persistent) Secp256r1 signer via `__constructor`. + * + * The returned {@link AssembledTransaction} still needs to be signed by the + * deployer keypair (the fee source) before submission. + */ +export async function buildDeployTransaction( + deps: { + rpcUrl: string; + networkPassphrase: string; + walletWasmHash: string; + deployerPublicKey: string; + timeoutInSeconds: number; + }, + keyId: Buffer, + publicKey: Uint8Array +): Promise> { + const signer: SDKSigner = { + tag: "Secp256r1", + values: [ + Buffer.from(keyId), + Buffer.from(publicKey), + [undefined], // SignerExpiration: none + [undefined], // SignerLimits: unlimited + { tag: "Persistent", values: undefined }, + ], + }; + + return PasskeyClient.deploy( + { signer }, + { + rpcUrl: deps.rpcUrl, + wasmHash: deps.walletWasmHash, + networkPassphrase: deps.networkPassphrase, + publicKey: deps.deployerPublicKey, + salt: hash(keyId), + timeoutInSeconds: deps.timeoutInSeconds, + } + ); +} diff --git a/src/kit/signatures.test.ts b/src/kit/signatures.test.ts new file mode 100644 index 0000000..c965216 --- /dev/null +++ b/src/kit/signatures.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { Address, xdr } from "@stellar/stellar-sdk"; +import type { Spec as ContractSpec } from "@stellar/stellar-sdk/contract"; +import { Client as PasskeyClient } from "passkey-kit-sdk"; +import type { SignerKey as SDKSignerKey } from "passkey-kit-sdk"; +import { + compareScVal, + signerKeyToScVal, + signatureToScVal, + upsertSignatureEntry, +} from "./auth-payload.js"; + +const TESTNET = "Test SDF Network ; September 2015"; +const CONTRACT = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL"; + +function walletSpec(): ContractSpec { + const client = new PasskeyClient({ + contractId: CONTRACT, + networkPassphrase: TESTNET, + rpcUrl: "https://rpc.example", + }); + return (client as unknown as { spec: ContractSpec }).spec; +} + +describe("compareScVal", () => { + it("orders by type discriminant first", () => { + // scvBool (0) sorts before scvU32 (4). + expect( + compareScVal(xdr.ScVal.scvBool(true), xdr.ScVal.scvU32(0)) + ).toBeLessThan(0); + }); + + it("orders bytes by content, with a shorter prefix first (host order)", () => { + const short = xdr.ScVal.scvBytes(Buffer.from([0xff])); + const long = xdr.ScVal.scvBytes(Buffer.from([0xff, 0x00])); + // Host compares element-wise then by length: [ff] is a prefix of [ff,00]. + expect(compareScVal(short, long)).toBeLessThan(0); + expect(compareScVal(long, short)).toBeGreaterThan(0); + expect(compareScVal(short, short)).toBe(0); + }); + + it("recurses into vectors (SignerKey encoding shape)", () => { + const a = xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol("Secp256r1"), + xdr.ScVal.scvBytes(Buffer.from([0x01])), + ]); + const b = xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol("Secp256r1"), + xdr.ScVal.scvBytes(Buffer.from([0x01, 0x00])), + ]); + expect(compareScVal(a, b)).toBeLessThan(0); + }); + + it("orders addresses by their encoding", () => { + const addr = xdr.ScVal.scvAddress( + Address.fromString(CONTRACT).toScAddress() + ); + expect(compareScVal(addr, addr)).toBe(0); + }); +}); + +describe("signerKeyToScVal", () => { + it("encodes a Secp256r1 signer key as a tagged vector", () => { + const spec = walletSpec(); + const key: SDKSignerKey = { + tag: "Secp256r1", + values: [Buffer.from([0xaa, 0xbb])], + }; + const scVal = signerKeyToScVal(spec, key); + expect(scVal.switch().name).toBe("scvVec"); + const vec = scVal.vec()!; + expect(vec[0]!.sym().toString()).toBe("Secp256r1"); + expect(Buffer.from(vec[1]!.bytes())).toEqual(Buffer.from([0xaa, 0xbb])); + }); +}); + +describe("upsertSignatureEntry", () => { + function credentials(): xdr.SorobanAddressCredentials { + return new xdr.SorobanAddressCredentials({ + address: Address.fromString(CONTRACT).toScAddress(), + nonce: xdr.Int64.fromString("1"), + signatureExpirationLedger: 1, + signature: xdr.ScVal.scvVoid(), + }); + } + + function keyBytesOf(entry: xdr.ScMapEntry): Buffer { + return Buffer.from(entry.key().vec()![1]!.bytes()); + } + + it("creates the Signatures vec+map from a void signature", () => { + const spec = walletSpec(); + const creds = credentials(); + const scKey = signerKeyToScVal(spec, { + tag: "Secp256r1", + values: [Buffer.from([0x01])], + }); + upsertSignatureEntry(creds, scKey, signatureToScVal(spec, undefined)); + + expect(creds.signature().switch().name).toBe("scvVec"); + const map = creds.signature().vec()![0]!.map()!; + expect(map).toHaveLength(1); + }); + + it("keeps map keys in host order across inserts", () => { + const spec = walletSpec(); + const creds = credentials(); + // Insert out of order: [ff] then [01]; expect sorted [01] before [ff]. + for (const b of [0xff, 0x01]) { + const scKey = signerKeyToScVal(spec, { + tag: "Secp256r1", + values: [Buffer.from([b])], + }); + upsertSignatureEntry(creds, scKey, xdr.ScVal.scvVoid()); + } + const map = creds.signature().vec()![0]!.map()!; + expect(map.map(keyBytesOf)).toEqual([Buffer.from([0x01]), Buffer.from([0xff])]); + }); + + it("replaces (dedupes) an existing key rather than duplicating it", () => { + const spec = walletSpec(); + const creds = credentials(); + const scKey = signerKeyToScVal(spec, { + tag: "Secp256r1", + values: [Buffer.from([0x07])], + }); + upsertSignatureEntry(creds, scKey, xdr.ScVal.scvVoid()); + upsertSignatureEntry(creds, scKey, xdr.ScVal.scvVoid()); + expect(creds.signature().vec()![0]!.map()!).toHaveLength(1); + }); +}); diff --git a/src/kit/tx-ops.ts b/src/kit/tx-ops.ts new file mode 100644 index 0000000..cf4a663 --- /dev/null +++ b/src/kit/tx-ops.ts @@ -0,0 +1,207 @@ +/** + * Transaction-signing operations: the auth-entry signing pipeline and the + * `AssembledTransaction` signing entrypoint. + * + * Two deliberate changes from the old kit: + * - `sign` takes a single explicit `AssembledTransaction` instead of the + * lossy `AssembledTransaction | Tx | string` tri-input that silently dropped + * memo/fee/operations on its fallback path (#599 §6). Callers holding XDR use + * `AssembledTransaction.fromXDR` first. + * - The `Signatures` map is sorted with the host-order `compareScVal`, not the + * old `localeCompare` string approximation. + * + * @packageDocumentation + */ + +import { Operation, TransactionBuilder, xdr } from "@stellar/stellar-sdk"; +import type { Keypair } from "@stellar/stellar-sdk"; +import type { Server } from "@stellar/stellar-sdk/rpc"; +import type { + AssembledTransaction, + Spec as ContractSpec, +} from "@stellar/stellar-sdk/contract"; +import type { Signer, SignerContext } from "../signers.js"; +import { + buildSignaturePayload, + getAddressCredentials, + signatureToScVal, + signerKeyToScVal, + upsertSignatureEntry, +} from "./auth-payload.js"; +import { SigningError, PasskeyKitError, PasskeyKitErrorCode } from "../errors.js"; + +/** Deps for computing a default signature-expiration ledger. */ +export interface ExpirationDeps { + rpc: Server; + timeoutInSeconds: number; +} + +/** + * Compute a default signature-expiration ledger: the latest ledger plus the + * timeout window (assuming ~5s ledgers), rounded up. + */ +export async function calculateExpiration(deps: ExpirationDeps): Promise { + const { sequence } = await deps.rpc.getLatestLedger(); + return Math.ceil(sequence + deps.timeoutInSeconds / 5); +} + +/** Deps for signing a single auth entry. */ +export interface SignAuthEntryDeps { + networkPassphrase: string; + spec: ContractSpec; + signerContext: SignerContext; + calculateExpiration: () => Promise; +} + +/** Per-call signing options. */ +export interface SignOptions { + /** Signature expiration ledger (defaults to the configured window). */ + expiration?: number; +} + +/** + * Sign a single Soroban authorization entry with a {@link Signer}, merging the + * resulting `(SignerKey, Signature)` pair into the entry's flat `Signatures` + * map (host-ordered). + * + * Mutates and returns the passed entry. + */ +export async function signAuthEntry( + deps: SignAuthEntryDeps, + entry: xdr.SorobanAuthorizationEntry, + signer: Signer, + options?: SignOptions +): Promise { + if ( + entry.credentials().switch().name === "sorobanCredentialsAddressWithDelegates" + ) { + throw new SigningError( + "ADDRESS_WITH_DELEGATES auth entries are not supported by passkey signing", + PasskeyKitErrorCode.UNSUPPORTED_CREDENTIALS + ); + } + + const credentials = getAddressCredentials(entry.credentials()); + + let expiration = options?.expiration; + if (!expiration) { + expiration = credentials.signatureExpirationLedger(); + if (!expiration) { + expiration = await deps.calculateExpiration(); + } + } + + // Sets credentials.signatureExpirationLedger(expiration) as a side effect. + const payload = buildSignaturePayload(deps.networkPassphrase, entry, expiration); + + const prepared = await signer.sign(payload, deps.signerContext); + const scKey = signerKeyToScVal(deps.spec, prepared.key); + const scVal = signatureToScVal(deps.spec, prepared.value); + upsertSignatureEntry(credentials, scKey, scVal); + + return entry; +} + +/** Deps for signing an assembled transaction's auth entries. */ +export interface SignTxDeps extends SignAuthEntryDeps { + getContractId: () => string | undefined; +} + +/** + * Sign every auth entry of an {@link AssembledTransaction} that is authorized by + * the connected wallet, using `signer`. Returns the same transaction with its + * auth entries signed. + * + * @throws {SigningError} If no wallet is connected. + */ +export async function sign( + deps: SignTxDeps, + txn: AssembledTransaction, + signer: Signer, + options?: SignOptions +): Promise> { + const contractId = deps.getContractId(); + if (!contractId) { + throw new SigningError( + "A wallet must be connected to sign a transaction", + PasskeyKitErrorCode.SIGNING_FAILED + ); + } + + await txn.signAuthEntries({ + address: contractId, + authorizeEntry: async (entry) => { + const clone = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR()); + return signAuthEntry(deps, clone, signer, options); + }, + }); + + return txn; +} + +/** + * The `restorePreamble` shape Soroban simulation returns when a transaction + * touches archived entries (a subset of the SDK's `SimulateTransactionResponse`). + */ +export interface RestorePreamble { + minResourceFee: string; + transactionData: { build(): xdr.SorobanTransactionData }; +} + +/** + * Restore an archived contract-data footprint reported by simulation, paying + * with the deployer keypair. + * + * Soroban simulation returns a `restorePreamble` when the transaction touches + * archived entries; the footprint must be restored (a separate, fee-bearing + * transaction) before the real transaction can succeed. The deployer keypair + * pays because it is the on-chain fee source the kit already controls. + * + * Returns the restore transaction hash. This path is exercised live in F2 (it + * requires archived on-chain state to trigger). + * + * @throws {SigningError} On submission/confirmation failure. + */ +export async function restoreFootprint( + deps: { + rpc: Server; + networkPassphrase: string; + deployerKeypair: Keypair; + timeoutInSeconds: number; + }, + restorePreamble: RestorePreamble +): Promise { + const account = await deps.rpc.getAccount(deps.deployerKeypair.publicKey()); + const fee = (Number(restorePreamble.minResourceFee) + 100_000).toString(); + + const restoreTx = new TransactionBuilder(account, { + fee, + networkPassphrase: deps.networkPassphrase, + }) + .addOperation(Operation.restoreFootprint({})) + .setSorobanData(restorePreamble.transactionData.build()) + .setTimeout(deps.timeoutInSeconds) + .build(); + + restoreTx.sign(deps.deployerKeypair); + + const sendResult = await deps.rpc.sendTransaction(restoreTx); + if (sendResult.status === "ERROR") { + throw new PasskeyKitError( + `Footprint restore submission failed: ${sendResult.errorResult?.toXDR("base64") ?? "unknown"}`, + PasskeyKitErrorCode.RESTORE_REQUIRED, + { context: { hash: sendResult.hash } } + ); + } + + const result = await deps.rpc.pollTransaction(sendResult.hash, { attempts: 10 }); + if (result.status !== "SUCCESS") { + throw new PasskeyKitError( + `Footprint restore did not confirm (status ${result.status})`, + PasskeyKitErrorCode.RESTORE_REQUIRED, + { context: { hash: sendResult.hash } } + ); + } + + return sendResult.hash; +} diff --git a/src/kit/wallet-ops.ts b/src/kit/wallet-ops.ts new file mode 100644 index 0000000..5aeb30c --- /dev/null +++ b/src/kit/wallet-ops.ts @@ -0,0 +1,204 @@ +/** + * Wallet signer operations: build add/update/remove signer transactions, encode + * the SDK-side {@link SignerKey}/{@link SignerLimits} helpers into the contract's + * union types, and read a signer entry back from the ledger. + * + * `getSigner` reads the flat top-level `SignerKey → SignerVal` entry directly via + * RPC (temporary durability before persistent, per the contract's lookup + * invariant). This doubles as `connectWallet`'s ownership check (#601 F7): a + * keyId that resolves to a live signer entry proves the wallet actually knows + * that passkey. When the contract's `get_signer` view lands (A3) this can move + * onto the view; the ledger read is equivalent and works today. + * + * @packageDocumentation + */ + +import { Keypair } from "@stellar/stellar-sdk"; +import { Durability, type Server } from "@stellar/stellar-sdk/rpc"; +import type { AssembledTransaction, Spec as ContractSpec } from "@stellar/stellar-sdk/contract"; +import { + Client as PasskeyClient, + type Signer as SDKSigner, + type SignerKey as SDKSignerKey, + type SignerLimits as SDKSignerLimits, + type SignerVal, +} from "passkey-kit-sdk"; +import base64url from "../base64url.js"; +import { SignerStore, type SignerKey, type SignerLimits } from "../types.js"; +import { signerKeyToScVal, SIGNER_VAL_UDT } from "./auth-payload.js"; + +/** Which write the builder targets. */ +type SignerFn = "add_signer" | "update_signer"; + +/** Encode a SDK-side {@link SignerKey} helper into the contract's union. */ +export function toContractSignerKey({ key, value }: SignerKey): SDKSignerKey { + switch (key) { + case "Policy": + return { tag: "Policy", values: [value] }; + case "Ed25519": + return { tag: "Ed25519", values: [Keypair.fromPublicKey(value).rawPublicKey()] }; + case "Secp256r1": + return { tag: "Secp256r1", values: [base64url.toBuffer(value)] }; + } +} + +/** Encode SDK-side {@link SignerLimits} into the contract's `SignerLimits`. */ +export function toContractSignerLimits(limits: SignerLimits): SDKSignerLimits { + if (!limits) { + return [undefined]; + } + + const map = new Map(); + for (const [contract, signerKeys] of limits.entries()) { + map.set( + contract, + signerKeys?.length ? signerKeys.map(toContractSignerKey) : undefined + ); + } + return [map]; +} + +function buildSecp256r1Signer( + keyId: string | Uint8Array, + publicKey: string | Uint8Array, + limits: SignerLimits, + store: SignerStore, + expiration?: number +): SDKSigner { + const keyIdBuffer = + typeof keyId === "string" ? base64url.toBuffer(keyId) : Buffer.from(keyId); + const publicKeyBuffer = + typeof publicKey === "string" + ? base64url.toBuffer(publicKey) + : Buffer.from(publicKey); + return { + tag: "Secp256r1", + values: [ + keyIdBuffer, + publicKeyBuffer, + [expiration], + toContractSignerLimits(limits), + { tag: store, values: undefined }, + ], + }; +} + +function buildEd25519Signer( + publicKey: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number +): SDKSigner { + return { + tag: "Ed25519", + values: [ + Keypair.fromPublicKey(publicKey).rawPublicKey(), + [expiration], + toContractSignerLimits(limits), + { tag: store, values: undefined }, + ], + }; +} + +function buildPolicySigner( + policy: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number +): SDKSigner { + return { + tag: "Policy", + values: [ + policy, + [expiration], + toContractSignerLimits(limits), + { tag: store, values: undefined }, + ], + }; +} + +/** Shared deps for building signer-write transactions. */ +export interface WalletWriteDeps { + wallet: PasskeyClient; + timeoutInSeconds: number; +} + +export function buildSecp256r1SignerTx( + deps: WalletWriteDeps, + fn: SignerFn, + keyId: string | Uint8Array, + publicKey: string | Uint8Array, + limits: SignerLimits, + store: SignerStore, + expiration?: number +): Promise> { + return deps.wallet[fn]( + { signer: buildSecp256r1Signer(keyId, publicKey, limits, store, expiration) }, + { timeoutInSeconds: deps.timeoutInSeconds } + ); +} + +export function buildEd25519SignerTx( + deps: WalletWriteDeps, + fn: SignerFn, + publicKey: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number +): Promise> { + return deps.wallet[fn]( + { signer: buildEd25519Signer(publicKey, limits, store, expiration) }, + { timeoutInSeconds: deps.timeoutInSeconds } + ); +} + +export function buildPolicySignerTx( + deps: WalletWriteDeps, + fn: SignerFn, + policy: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number +): Promise> { + return deps.wallet[fn]( + { signer: buildPolicySigner(policy, limits, store, expiration) }, + { timeoutInSeconds: deps.timeoutInSeconds } + ); +} + +export function buildRemoveSignerTx( + deps: WalletWriteDeps, + signerKey: SignerKey +): Promise> { + return deps.wallet.remove_signer( + { signer_key: toContractSignerKey(signerKey) }, + { timeoutInSeconds: deps.timeoutInSeconds } + ); +} + +/** + * Read a signer entry from the ledger, or `null` if absent. + * + * Checks temporary durability before persistent (the contract's lookup order). + * A non-null result proves the wallet holds this signer — the basis of + * `connectWallet`'s ownership verification. + */ +export async function getSigner( + deps: { rpc: Server; spec: ContractSpec }, + contractId: string, + signerKey: SignerKey +): Promise { + const scKey = signerKeyToScVal(deps.spec, toContractSignerKey(signerKey)); + + for (const durability of [Durability.Temporary, Durability.Persistent]) { + try { + const entry = await deps.rpc.getContractData(contractId, scKey, durability); + const scVal = entry.val.contractData().val(); + return deps.spec.scValToNative(scVal, SIGNER_VAL_UDT) as SignerVal; + } catch { + // Not present in this durability class; try the next. + } + } + + return null; +} diff --git a/src/kit/webauthn-ops.ts b/src/kit/webauthn-ops.ts new file mode 100644 index 0000000..7d29ffb --- /dev/null +++ b/src/kit/webauthn-ops.ts @@ -0,0 +1,141 @@ +/** + * WebAuthn ceremony operations: passkey registration and authentication. + * + * Pure functions over an injected WebAuthn implementation and RP config, so the + * ceremonies are unit-testable with a fake authenticator. The old kit hard-coded + * a static challenge (`"stellaristhebetterblockchain"`) and read `rpId` per + * call; here the challenge is random (see {@link generateChallenge}) and the RP + * config comes from the kit constructor. + * + * @packageDocumentation + */ + +import type { + AuthenticationResponseJSON, + RegistrationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + AuthenticatorSelectionCriteria, +} from "@simplewebauthn/browser"; +import base64url from "../base64url.js"; +import { extractPublicKeyFromAttestation, generateChallenge } from "../utils.js"; +import { WEBAUTHN_TIMEOUT_MS } from "../constants.js"; +import { WebAuthnError, PasskeyKitErrorCode } from "../errors.js"; + +/** The WebAuthn ceremony surface the kit depends on (overridable for tests). */ +export interface WebAuthnClient { + startRegistration(args: { + optionsJSON: PublicKeyCredentialCreationOptionsJSON; + }): Promise; + startAuthentication(args: { + optionsJSON: PublicKeyCredentialRequestOptionsJSON; + }): Promise; +} + +/** RP config + WebAuthn implementation for the ceremonies. */ +export interface WebAuthnDeps { + rpId?: string; + webAuthn: WebAuthnClient; +} + +/** A freshly registered passkey. */ +export interface CreatedPasskey { + rawResponse: RegistrationResponseJSON; + /** Base64URL credential id. */ + keyId: string; + /** Raw credential id bytes. */ + keyIdBuffer: Buffer; + /** 65-byte uncompressed secp256r1 public key. */ + publicKey: Uint8Array; +} + +/** + * Run a WebAuthn registration ceremony and extract the passkey public key. + * + * `pubKeyCredParams` is ES256-only (the smart wallet verifies secp256r1). + */ +export async function createPasskey( + deps: WebAuthnDeps, + appName: string, + userName: string, + authenticatorSelection: AuthenticatorSelectionCriteria = { + residentKey: "preferred", + userVerification: "preferred", + } +): Promise { + const now = new Date(); + const displayName = `${userName} — ${now.toLocaleString()}`; + + const optionsJSON: PublicKeyCredentialCreationOptionsJSON = { + challenge: generateChallenge(), + rp: { id: deps.rpId, name: appName }, + user: { + id: generateChallenge(), + name: displayName, + displayName, + }, + authenticatorSelection, + pubKeyCredParams: [{ alg: -7, type: "public-key" }], + timeout: WEBAUTHN_TIMEOUT_MS, + }; + + let rawResponse: RegistrationResponseJSON; + try { + rawResponse = await deps.webAuthn.startRegistration({ optionsJSON }); + } catch (err) { + throw new WebAuthnError( + "Passkey registration failed", + PasskeyKitErrorCode.WEBAUTHN_REGISTRATION_FAILED, + err instanceof Error ? err : undefined + ); + } + + const publicKey = await extractPublicKeyFromAttestation(rawResponse.response); + + return { + rawResponse, + keyId: rawResponse.id, + keyIdBuffer: base64url.toBuffer(rawResponse.id), + publicKey, + }; +} + +/** A discoverable-credential authentication result. */ +export interface AuthenticatedPasskey { + keyId: string; + keyIdBuffer: Buffer; + rawResponse: AuthenticationResponseJSON; +} + +/** + * Run a WebAuthn authentication ceremony against a discoverable credential (no + * `allowCredentials`), returning the selected credential id. Used by + * `connectWallet` to discover which passkey the user picked. + */ +export async function authenticatePasskey( + deps: WebAuthnDeps +): Promise { + const optionsJSON: PublicKeyCredentialRequestOptionsJSON = { + challenge: generateChallenge(), + rpId: deps.rpId, + userVerification: "preferred", + timeout: WEBAUTHN_TIMEOUT_MS, + }; + + let rawResponse: AuthenticationResponseJSON; + try { + rawResponse = await deps.webAuthn.startAuthentication({ optionsJSON }); + } catch (err) { + throw new WebAuthnError( + "Passkey authentication failed", + PasskeyKitErrorCode.WEBAUTHN_AUTHENTICATION_FAILED, + err instanceof Error ? err : undefined + ); + } + + return { + keyId: rawResponse.id, + keyIdBuffer: base64url.toBuffer(rawResponse.id), + rawResponse, + }; +} diff --git a/src/managers/credential-manager.ts b/src/managers/credential-manager.ts new file mode 100644 index 0000000..2f23bd2 --- /dev/null +++ b/src/managers/credential-manager.ts @@ -0,0 +1,76 @@ +/** + * Credential manager: WebAuthn passkey ceremonies + optional passkey-record + * persistence. + * + * Constructed from a plain {@link CredentialManagerDeps} object so it is fully + * unit-testable with `vi.fn()` fakes (no live authenticator or RPC needed). + * + * @packageDocumentation + */ + +import type { AuthenticatorSelectionCriteria } from "@simplewebauthn/browser"; +import type { StorageAdapter, StoredPasskey } from "../types.js"; +import { + authenticatePasskey, + createPasskey, + type AuthenticatedPasskey, + type CreatedPasskey, + type WebAuthnClient, +} from "../kit/webauthn-ops.js"; + +export interface CredentialManagerDeps { + rpId?: string; + webAuthn: WebAuthnClient; + /** Optional passkey-record store; when absent, remember/lookup are no-ops. */ + storage?: StorageAdapter; +} + +export class CredentialManager { + constructor(private readonly deps: CredentialManagerDeps) {} + + private get webAuthnDeps() { + return { rpId: this.deps.rpId, webAuthn: this.deps.webAuthn }; + } + + /** Run a registration ceremony, returning the new passkey + its public key. */ + createKey( + appName: string, + userName: string, + authenticatorSelection?: AuthenticatorSelectionCriteria + ): Promise { + return createPasskey( + this.webAuthnDeps, + appName, + userName, + authenticatorSelection + ); + } + + /** Run a discoverable-credential authentication ceremony. */ + authenticate(): Promise { + return authenticatePasskey(this.webAuthnDeps); + } + + /** Persist a passkey → wallet association (no-op without a storage adapter). */ + async rememberPasskey(record: StoredPasskey): Promise { + await this.deps.storage?.save(record); + } + + /** Remove a persisted passkey record (no-op without a storage adapter). */ + async forgetPasskey(keyId: string): Promise { + await this.deps.storage?.delete(keyId); + } + + /** Look up a stored passkey record by keyId. */ + async getPasskey(keyId: string): Promise { + return (await this.deps.storage?.get(keyId)) ?? null; + } + + /** + * Resolve a wallet contract id for a keyId from local storage, if a record + * exists. Used as an indexer-free `connectWallet` fallback. + */ + async lookupContractId(keyId: string): Promise { + return (await this.getPasskey(keyId))?.contractId; + } +} diff --git a/src/managers/index.ts b/src/managers/index.ts new file mode 100644 index 0000000..a70f9fd --- /dev/null +++ b/src/managers/index.ts @@ -0,0 +1,13 @@ +/** + * Dependency-injected managers that compose the kit facade. + */ + +export { + CredentialManager, + type CredentialManagerDeps, +} from "./credential-manager.js"; +export { SignerManager, type SignerManagerDeps } from "./signer-manager.js"; +export { + SubmissionManager, + type SubmissionManagerDeps, +} from "./submission-manager.js"; diff --git a/src/managers/signer-manager.test.ts b/src/managers/signer-manager.test.ts new file mode 100644 index 0000000..6863224 --- /dev/null +++ b/src/managers/signer-manager.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import type { Spec as ContractSpec } from "@stellar/stellar-sdk/contract"; +import { Client as PasskeyClient } from "passkey-kit-sdk"; +import { SignerManager, type SignerManagerDeps } from "./signer-manager.js"; +import { SignerKey, SignerStore } from "../types.js"; +import { WalletNotConnectedError } from "../errors.js"; +import base64url from "../base64url.js"; + +const TESTNET = "Test SDF Network ; September 2015"; +const CONTRACT = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL"; + +function realSpec(): ContractSpec { + return ( + new PasskeyClient({ + contractId: CONTRACT, + networkPassphrase: TESTNET, + rpcUrl: "https://rpc.example", + }) as unknown as { spec: ContractSpec } + ).spec; +} + +function makeDeps(overrides: Partial = {}) { + const wallet = { + spec: realSpec(), + options: { contractId: CONTRACT }, + add_signer: vi.fn(async () => "AT_ADD"), + update_signer: vi.fn(async () => "AT_UPDATE"), + remove_signer: vi.fn(async () => "AT_REMOVE"), + }; + const rpc = { + getContractData: vi.fn(async () => { + throw new Error("not found"); + }), + }; + const deps: SignerManagerDeps = { + networkPassphrase: TESTNET, + timeoutInSeconds: 30, + rpc: rpc as never, + getWallet: () => wallet as never, + getContractId: () => CONTRACT, + getSignerContext: () => ({ webAuthn: { startAuthentication: vi.fn() } as never }), + calculateExpiration: vi.fn(async () => 100), + ...overrides, + }; + return { deps, wallet, rpc }; +} + +describe("SignerManager signer writes", () => { + it("addEd25519 calls wallet.add_signer with the encoded Ed25519 signer", async () => { + const { deps, wallet } = makeDeps(); + const manager = new SignerManager(deps); + const pk = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3)).publicKey(); + + const result = await manager.addEd25519(pk, undefined, SignerStore.Persistent); + + expect(result).toBe("AT_ADD"); + expect(wallet.add_signer).toHaveBeenCalledTimes(1); + const [{ signer }, opts] = wallet.add_signer.mock.calls[0]!; + expect(signer.tag).toBe("Ed25519"); + expect(signer.values[3]).toEqual({ tag: "Persistent", values: undefined }); + expect(opts).toEqual({ timeoutInSeconds: 30 }); + }); + + it("addSecp256r1 encodes keyId/publicKey and expiration/limits", async () => { + const { deps, wallet } = makeDeps(); + const manager = new SignerManager(deps); + const keyId = base64url.encode(Buffer.alloc(16, 1)); + const publicKey = base64url.encode(Buffer.alloc(65, 4)); + + await manager.addSecp256r1(keyId, publicKey, undefined, SignerStore.Temporary, 777); + + const [{ signer }] = wallet.add_signer.mock.calls[0]!; + expect(signer.tag).toBe("Secp256r1"); + expect(signer.values[2]).toEqual([777]); // SignerExpiration + expect(signer.values[3]).toEqual([undefined]); // unlimited SignerLimits + expect(signer.values[4]).toEqual({ tag: "Temporary", values: undefined }); + }); + + it("remove calls wallet.remove_signer with the encoded key", async () => { + const { deps, wallet } = makeDeps(); + const manager = new SignerManager(deps); + + await manager.remove(SignerKey.Ed25519( + Keypair.fromRawEd25519Seed(Buffer.alloc(32, 5)).publicKey() + )); + + expect(wallet.remove_signer).toHaveBeenCalledTimes(1); + const [{ signer_key }] = wallet.remove_signer.mock.calls[0]!; + expect(signer_key.tag).toBe("Ed25519"); + }); + + it("throws WalletNotConnectedError when no wallet is connected", () => { + const { deps } = makeDeps({ getWallet: () => undefined }); + const manager = new SignerManager(deps); + expect(() => + manager.addPolicy(CONTRACT, undefined, SignerStore.Persistent) + ).toThrow(WalletNotConnectedError); + }); +}); + +describe("SignerManager.sign", () => { + it("signs the transaction's auth entries against the connected contract", async () => { + const { deps } = makeDeps(); + const manager = new SignerManager(deps); + const signAuthEntries = vi.fn(async () => {}); + const txn = { signAuthEntries } as never; + + const returned = await manager.sign(txn, { sign: vi.fn() }); + + expect(returned).toBe(txn); + expect(signAuthEntries).toHaveBeenCalledTimes(1); + expect(signAuthEntries.mock.calls[0]![0].address).toBe(CONTRACT); + }); +}); + +describe("SignerManager.getSigner", () => { + it("returns null when the signer entry is absent in both durabilities", async () => { + const { deps, rpc } = makeDeps(); + const manager = new SignerManager(deps); + + const result = await manager.getSigner( + SignerKey.Secp256r1(base64url.encode(Buffer.alloc(16, 9))) + ); + + expect(result).toBeNull(); + expect(rpc.getContractData).toHaveBeenCalledTimes(2); // temporary + persistent + }); +}); diff --git a/src/managers/signer-manager.ts b/src/managers/signer-manager.ts new file mode 100644 index 0000000..b76fbdf --- /dev/null +++ b/src/managers/signer-manager.ts @@ -0,0 +1,181 @@ +/** + * Signer manager: the signing pipeline and signer-write builders for the + * connected wallet. + * + * Owns nothing directly — every wallet-dependent value is a late-bound closure + * ({@link SignerManagerDeps.getWallet} / `getContractId` / `getSpec`) supplied by + * the kit facade, so the manager is constructed once and always sees current + * state. That indirection is also what makes it `vi.fn()`-testable. + * + * @packageDocumentation + */ + +import { xdr } from "@stellar/stellar-sdk"; +import type { Server } from "@stellar/stellar-sdk/rpc"; +import type { + AssembledTransaction, + Spec as ContractSpec, +} from "@stellar/stellar-sdk/contract"; +import type { Client as PasskeyClient, SignerVal } from "passkey-kit-sdk"; +import type { Signer, SignerContext } from "../signers.js"; +import { PasskeySigner } from "../signers.js"; +import { + SignerStore, + type SignerKey, + type SignerLimits, +} from "../types.js"; +import { WalletNotConnectedError } from "../errors.js"; +import { + signAuthEntry as signAuthEntryOp, + sign as signOp, + type SignOptions, +} from "../kit/tx-ops.js"; +import { + buildEd25519SignerTx, + buildPolicySignerTx, + buildRemoveSignerTx, + buildSecp256r1SignerTx, + getSigner as getSignerOp, +} from "../kit/wallet-ops.js"; + +/** Extract the contract `Spec` from a generated client (not in the public type). */ +function specOf(wallet: PasskeyClient): ContractSpec { + return (wallet as unknown as { spec: ContractSpec }).spec; +} + +export interface SignerManagerDeps { + networkPassphrase: string; + timeoutInSeconds: number; + rpc: Server; + getWallet: () => PasskeyClient | undefined; + getContractId: () => string | undefined; + getSignerContext: () => SignerContext; + calculateExpiration: () => Promise; +} + +export class SignerManager { + constructor(private readonly deps: SignerManagerDeps) {} + + private requireWallet(): { wallet: PasskeyClient; spec: ContractSpec } { + const wallet = this.deps.getWallet(); + if (!wallet) { + throw new WalletNotConnectedError("perform a signer operation"); + } + return { wallet, spec: specOf(wallet) }; + } + + private writeDeps() { + return { + wallet: this.requireWallet().wallet, + timeoutInSeconds: this.deps.timeoutInSeconds, + }; + } + + // -- Signing pipeline -------------------------------------------------------- + + private signAuthEntryDeps() { + return { + networkPassphrase: this.deps.networkPassphrase, + spec: this.requireWallet().spec, + signerContext: this.deps.getSignerContext(), + calculateExpiration: this.deps.calculateExpiration, + }; + } + + /** Sign a single auth entry with `signer` (defaults to the connected passkey). */ + signAuthEntry( + entry: xdr.SorobanAuthorizationEntry, + signer: Signer = new PasskeySigner(), + options?: SignOptions + ): Promise { + return signAuthEntryOp(this.signAuthEntryDeps(), entry, signer, options); + } + + /** Sign an assembled transaction's wallet auth entries. */ + sign( + txn: AssembledTransaction, + signer: Signer = new PasskeySigner(), + options?: SignOptions + ): Promise> { + return signOp( + { ...this.signAuthEntryDeps(), getContractId: this.deps.getContractId }, + txn, + signer, + options + ); + } + + // -- Signer writes ----------------------------------------------------------- + + addSecp256r1( + keyId: string | Uint8Array, + publicKey: string | Uint8Array, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return buildSecp256r1SignerTx(this.writeDeps(), "add_signer", keyId, publicKey, limits, store, expiration); + } + + updateSecp256r1( + keyId: string | Uint8Array, + publicKey: string | Uint8Array, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return buildSecp256r1SignerTx(this.writeDeps(), "update_signer", keyId, publicKey, limits, store, expiration); + } + + addEd25519( + publicKey: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return buildEd25519SignerTx(this.writeDeps(), "add_signer", publicKey, limits, store, expiration); + } + + updateEd25519( + publicKey: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return buildEd25519SignerTx(this.writeDeps(), "update_signer", publicKey, limits, store, expiration); + } + + addPolicy( + policy: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return buildPolicySignerTx(this.writeDeps(), "add_signer", policy, limits, store, expiration); + } + + updatePolicy( + policy: string, + limits: SignerLimits, + store: SignerStore, + expiration?: number + ): Promise> { + return buildPolicySignerTx(this.writeDeps(), "update_signer", policy, limits, store, expiration); + } + + remove(signerKey: SignerKey): Promise> { + return buildRemoveSignerTx(this.writeDeps(), signerKey); + } + + // -- Reads ------------------------------------------------------------------- + + /** Read a signer entry from the ledger (temporary before persistent). */ + getSigner(signerKey: SignerKey): Promise { + const { spec } = this.requireWallet(); + const contractId = this.deps.getContractId(); + if (!contractId) { + throw new WalletNotConnectedError("read a signer"); + } + return getSignerOp({ rpc: this.deps.rpc, spec }, contractId, signerKey); + } +} diff --git a/src/managers/submission-manager.ts b/src/managers/submission-manager.ts new file mode 100644 index 0000000..8c70189 --- /dev/null +++ b/src/managers/submission-manager.ts @@ -0,0 +1,99 @@ +/** + * Submission manager: builds and signs the deploy transaction with the fee- + * paying deployer keypair, derives deterministic wallet addresses, and drives + * the footprint-restore flow. + * + * Network submission itself is the server's job (`PasskeyServer` / relayer); this + * manager owns the deployer keypair and the fee-payer signature that make a + * deploy transaction submittable. + * + * @packageDocumentation + */ + +import type { Keypair } from "@stellar/stellar-sdk"; +import { basicNodeSigner } from "@stellar/stellar-sdk/contract"; +import type { AssembledTransaction } from "@stellar/stellar-sdk/contract"; +import type { Server } from "@stellar/stellar-sdk/rpc"; +import type { Client as PasskeyClient } from "passkey-kit-sdk"; +import { + buildDeployTransaction as buildDeployTransactionOp, + deriveWalletAddress as deriveWalletAddressOp, +} from "../kit/deploy-ops.js"; +import { + restoreFootprint as restoreFootprintOp, + type RestorePreamble, +} from "../kit/tx-ops.js"; + +export interface SubmissionManagerDeps { + rpc: Server; + rpcUrl: string; + networkPassphrase: string; + walletWasmHash: string; + deployerKeypair: Keypair; + timeoutInSeconds: number; +} + +export class SubmissionManager { + constructor(private readonly deps: SubmissionManagerDeps) {} + + /** The deployer's `G…` public key (fee source + derivation deployer). */ + get deployerPublicKey(): string { + return this.deps.deployerKeypair.publicKey(); + } + + /** Deterministically derive the wallet address for a passkey credential. */ + deriveWalletAddress(keyId: Buffer): string { + return deriveWalletAddressOp( + { + networkPassphrase: this.deps.networkPassphrase, + deployerPublicKey: this.deployerPublicKey, + }, + keyId + ); + } + + /** Build the wallet deploy transaction (initial Secp256r1 signer). */ + buildDeployTransaction( + keyId: Buffer, + publicKey: Uint8Array + ): Promise> { + return buildDeployTransactionOp( + { + rpcUrl: this.deps.rpcUrl, + networkPassphrase: this.deps.networkPassphrase, + walletWasmHash: this.deps.walletWasmHash, + deployerPublicKey: this.deployerPublicKey, + timeoutInSeconds: this.deps.timeoutInSeconds, + }, + keyId, + publicKey + ); + } + + /** + * Sign a deploy transaction with the deployer keypair (the fee source) and + * return the signed transaction XDR. + */ + async signDeploy(tx: AssembledTransaction): Promise { + await tx.sign({ + signTransaction: basicNodeSigner( + this.deps.deployerKeypair, + this.deps.networkPassphrase + ).signTransaction, + }); + return tx.signed!.toXDR(); + } + + /** Restore an archived footprint reported by simulation. */ + restoreFootprint(restorePreamble: RestorePreamble): Promise { + return restoreFootprintOp( + { + rpc: this.deps.rpc, + networkPassphrase: this.deps.networkPassphrase, + deployerKeypair: this.deps.deployerKeypair, + timeoutInSeconds: this.deps.timeoutInSeconds, + }, + restorePreamble + ); + } +} diff --git a/src/signers.test.ts b/src/signers.test.ts new file mode 100644 index 0000000..54f2a37 --- /dev/null +++ b/src/signers.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import base64url from "./base64url.js"; +import { + Ed25519Signer, + PasskeySigner, + PolicySigner, + type SignerContext, +} from "./signers.js"; + +const POLICY = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL"; + +/** Build a minimal DER ECDSA signature for (r, s) = (1, 2). */ +function derSig(): Buffer { + return Buffer.from([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]); +} + +describe("Ed25519Signer", () => { + it("produces an Ed25519 (key, value) pair with a verifiable signature", () => { + const keypair = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)); + const signer = new Ed25519Signer(keypair); + const payload = Buffer.alloc(32, 9); + + const { key, value } = signer.sign(payload); + + expect(key.tag).toBe("Ed25519"); + expect(Buffer.from(key.values[0])).toEqual( + Buffer.from(keypair.rawPublicKey()) + ); + expect(value?.tag).toBe("Ed25519"); + const signature = (value as { values: readonly [Buffer] }).values[0]; + expect(signature).toHaveLength(64); + expect(keypair.verify(payload, Buffer.from(signature))).toBe(true); + }); + + it("fromSecret rejects an invalid secret key", () => { + expect(() => Ed25519Signer.fromSecret("not-a-secret")).toThrow(); + }); +}); + +describe("PolicySigner", () => { + it("produces a Policy key with a void value", () => { + const { key, value } = new PolicySigner(POLICY).sign(); + expect(key).toEqual({ tag: "Policy", values: [POLICY] }); + expect(value).toEqual({ tag: "Policy", values: undefined }); + }); +}); + +describe("PasskeySigner", () => { + function fakeAuthenticator(keyId: string) { + return { + startAuthentication: vi.fn(async () => ({ + id: keyId, + rawId: keyId, + type: "public-key" as const, + clientExtensionResults: {}, + response: { + authenticatorData: base64url.encode(Buffer.alloc(37, 1)), + clientDataJSON: base64url.encode(Buffer.from("{}")), + signature: base64url.encode(derSig()), + }, + })), + }; + } + + it("runs the authentication ceremony and returns a Secp256r1 pair", async () => { + const keyId = base64url.encode(Buffer.alloc(16, 0xab)); + const authenticator = fakeAuthenticator(keyId); + const context: SignerContext = { webAuthn: authenticator, rpId: "example.com" }; + + const signer = new PasskeySigner(keyId); + const { key, value } = await signer.sign(Buffer.alloc(32, 5), context); + + expect(key.tag).toBe("Secp256r1"); + expect(Buffer.from(key.values[0])).toEqual(base64url.toBuffer(keyId)); + expect(value?.tag).toBe("Secp256r1"); + const sig = (value as { values: readonly [{ signature: Buffer }] }).values[0]; + expect(sig.signature).toHaveLength(64); + + // Requested a specific credential -> allowCredentials must be set. + const optionsJSON = authenticator.startAuthentication.mock.calls[0]![0] + .optionsJSON; + expect(optionsJSON.allowCredentials).toEqual([ + { id: keyId, type: "public-key" }, + ]); + expect(optionsJSON.rpId).toBe("example.com"); + }); + + it('omits allowCredentials for a discoverable ("any") credential', async () => { + const keyId = base64url.encode(Buffer.alloc(16, 0xcd)); + const authenticator = fakeAuthenticator(keyId); + const context: SignerContext = { webAuthn: authenticator }; + + await new PasskeySigner("any").sign(Buffer.alloc(32, 1), context); + + const optionsJSON = authenticator.startAuthentication.mock.calls[0]![0] + .optionsJSON; + expect(optionsJSON.allowCredentials).toBeUndefined(); + }); + + it("falls back to the context default keyId when none is provided", async () => { + const defaultKeyId = base64url.encode(Buffer.alloc(16, 0x11)); + const authenticator = fakeAuthenticator(defaultKeyId); + const context: SignerContext = { webAuthn: authenticator, defaultKeyId }; + + await new PasskeySigner().sign(Buffer.alloc(32, 1), context); + + const optionsJSON = authenticator.startAuthentication.mock.calls[0]![0] + .optionsJSON; + expect(optionsJSON.allowCredentials).toEqual([ + { id: defaultKeyId, type: "public-key" }, + ]); + }); +}); diff --git a/src/signers.ts b/src/signers.ts new file mode 100644 index 0000000..8c3ecff --- /dev/null +++ b/src/signers.ts @@ -0,0 +1,196 @@ +/** + * Typed signer abstraction for the flat `Signatures` map. + * + * A {@link Signer} knows how to authenticate a signature payload (the 32-byte + * hash from `buildSignaturePayload`) and produce the on-chain + * `(SignerKey, Signature)` pair the wallet's `__check_auth` expects. This + * replaces the old `sign({ keyId | keypair | policy })` option trio, where the + * caller had to pass exactly one of three mutually-exclusive fields, with three + * explicit signer classes: + * + * - {@link PasskeySigner} — a WebAuthn secp256r1 passkey (default). + * - {@link Ed25519Signer} — a local Stellar keypair. + * - {@link PolicySigner} — a policy contract co-signer (no signature bytes). + * + * @packageDocumentation + */ + +import { Keypair } from "@stellar/stellar-sdk"; +import type { + AuthenticationResponseJSON, + PublicKeyCredentialRequestOptionsJSON, +} from "@simplewebauthn/browser"; +import type { + Signature as SDKSignature, + SignerKey as SDKSignerKey, +} from "passkey-kit-sdk"; +import base64url from "./base64url.js"; +import { compactSignature } from "./utils.js"; +import { ValidationError, PasskeyKitErrorCode } from "./errors.js"; +import { WEBAUTHN_TIMEOUT_MS } from "./constants.js"; + +/** The WebAuthn `startAuthentication` surface a {@link PasskeySigner} needs. */ +export interface WebAuthnAuthenticator { + startAuthentication(args: { + optionsJSON: PublicKeyCredentialRequestOptionsJSON; + }): Promise; +} + +/** Ambient context passed to every {@link Signer.sign} call by the kit. */ +export interface SignerContext { + /** WebAuthn Relying Party id (domain); defaults to the current origin. */ + rpId?: string; + /** The WebAuthn implementation (overridable for tests). */ + webAuthn: WebAuthnAuthenticator; + /** The connected wallet's default passkey keyId (base64url), when known. */ + defaultKeyId?: string; +} + +/** The on-chain `(SignerKey, Signature)` pair a signer produces. */ +export interface PreparedSignature { + key: SDKSignerKey; + /** `undefined` for policy signers (the map stores `scvVoid` for the value). */ + value: SDKSignature | undefined; +} + +/** A signer for the wallet's flat `Signatures` map. */ +export interface Signer { + /** Authenticate a signature payload into an on-chain signature pair. */ + sign( + payload: Buffer, + context: SignerContext + ): Promise | PreparedSignature; +} + +/** + * An Ed25519 signer backed by a local Stellar keypair. + */ +export class Ed25519Signer implements Signer { + private readonly keypair: Keypair; + + constructor(keypair: Keypair) { + this.keypair = keypair; + } + + /** + * Build an {@link Ed25519Signer} from a Stellar secret key (`S…`). + * + * @throws {ValidationError} If the secret key is invalid. + */ + static fromSecret(secretKey: string): Ed25519Signer { + try { + return new Ed25519Signer(Keypair.fromSecret(secretKey)); + } catch { + throw new ValidationError( + "Invalid Ed25519 secret key (must be a valid Stellar secret key, S…)" + ); + } + } + + /** The signer's `G…` public key. */ + get address(): string { + return this.keypair.publicKey(); + } + + sign(payload: Buffer): PreparedSignature { + const signature = this.keypair.sign(payload); + return { + key: { tag: "Ed25519", values: [Buffer.from(this.keypair.rawPublicKey())] }, + value: { tag: "Ed25519", values: [signature] }, + }; + } +} + +/** + * A policy signer: a policy contract co-authorizes the entry. The map value is + * `scvVoid`; the wallet calls the policy's `policy__` during `__check_auth`. + */ +export class PolicySigner implements Signer { + private readonly policy: string; + + constructor(policy: string) { + this.policy = policy; + } + + sign(): PreparedSignature { + return { + key: { tag: "Policy", values: [this.policy] }, + value: { tag: "Policy", values: undefined }, + }; + } +} + +/** + * A WebAuthn secp256r1 passkey signer. + * + * Pass a specific `keyId` to require that credential, `"any"` to let the + * authenticator pick a discoverable credential, or omit it to use the kit's + * connected passkey ({@link SignerContext.defaultKeyId}). + */ +export class PasskeySigner implements Signer { + private readonly keyId: "any" | string | undefined; + + constructor(keyId?: "any" | string | Uint8Array) { + this.keyId = + keyId instanceof Uint8Array ? base64url(Buffer.from(keyId)) : keyId; + } + + async sign( + payload: Buffer, + context: SignerContext + ): Promise { + const requestedKeyId = + this.keyId === "any" ? undefined : this.keyId ?? context.defaultKeyId; + + // No specific credential → let the authenticator choose a discoverable one. + const discoverable = this.keyId === "any" || requestedKeyId === undefined; + + const optionsJSON: PublicKeyCredentialRequestOptionsJSON = { + challenge: base64url(payload), + rpId: context.rpId, + userVerification: "preferred", + timeout: WEBAUTHN_TIMEOUT_MS, + ...(discoverable + ? {} + : { + allowCredentials: [{ id: requestedKeyId!, type: "public-key" }], + }), + }; + + let response: AuthenticationResponseJSON; + try { + response = await context.webAuthn.startAuthentication({ optionsJSON }); + } catch (err) { + throw new ValidationError( + "WebAuthn authentication failed", + PasskeyKitErrorCode.INVALID_INPUT, + { cause: err instanceof Error ? err.message : String(err) } + ); + } + + return { + key: { + tag: "Secp256r1", + values: [base64url.toBuffer(response.id)], + }, + value: { + tag: "Secp256r1", + values: [ + { + authenticator_data: base64url.toBuffer( + response.response.authenticatorData + ), + client_data_json: base64url.toBuffer( + response.response.clientDataJSON + ), + signature: Buffer.from( + compactSignature( + base64url.toBuffer(response.response.signature) + ) + ), + }, + ], + }, + }; + } +} From ff7bd227a30db8c0613a46ccebccc8d81f94ef04 Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 21:38:53 -0400 Subject: [PATCH 08/30] =?UTF-8?q?test(contracts):=20full=20Rust=20suite=20?= =?UTF-8?q?=E2=80=94=20WebAuthn=20vectors,=20auth=20semantics,=20admin/eve?= =?UTF-8?q?nts=20(62=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_webauthn: deterministic RFC6979 secp256r1 assertions shaped like real authenticator output; happy path, UP/UV flags, truncated authData, wrong type, challenge mismatch (payload binding), oversized/malformed cdj, corrupted signature, key confusion, fixed challenge vector - test_auth: limits branches (unlimited/empty/granted/co-signer present, missing, expired), expiration boundary (inclusive), signatures-map hygiene (unknown/expired extras fail deterministically), self-remove rule + F5 foreign-contract non-interference, deploy-requires-unlimited, wallet-self admin grant, policy-as-signature vs policy-as-limit granularity (counting policy), cyclic policy limits fail closed via depth guard - test_admin: constructor/add/update/remove/upgrade + full event assertions, durability-flip opposing-entry removal, get_signer raw view, policy install/uninstall lifecycle (rejecting install blocks add; panicking uninstall cannot block removal) - test_base64_url: RFC 4648 vectors, url-safe alphabet, reference cross-check - test_integration: full set_auths flows for ed25519+policy AND the previously-untested secp256r1 passkey path (F1); budget regression print - build.rs builds wasm fixtures (sample-policy + smart-wallet) into OUT_DIR and copies to gitignored fixtures/ — cargo test self-sufficient on clean checkout; wasm contract builds skip the script entirely Co-Authored-By: Claude Fable 5 --- contracts/Cargo.lock | 229 +--- contracts/Cargo.toml | 3 +- contracts/Makefile | 4 +- contracts/smart-wallet/.gitignore | 1 + contracts/smart-wallet/Cargo.toml | 3 +- contracts/smart-wallet/build.rs | 51 + contracts/smart-wallet/src/lib.rs | 6 +- contracts/smart-wallet/src/tests/mod.rs | 10 + contracts/smart-wallet/src/tests/test.rs | 329 ----- .../smart-wallet/src/tests/test_admin.rs | 698 +++++++++++ contracts/smart-wallet/src/tests/test_auth.rs | 1083 +++++++++++++++++ .../smart-wallet/src/tests/test_base64_url.rs | 67 + .../smart-wallet/src/tests/test_common.rs | 319 +++++ .../smart-wallet/src/tests/test_deploy.rs | 89 ++ .../smart-wallet/src/tests/test_extra.rs | 131 -- .../src/tests/test_integration.rs | 226 ++++ .../smart-wallet/src/tests/test_webauthn.rs | 322 +++++ 17 files changed, 2887 insertions(+), 684 deletions(-) create mode 100644 contracts/smart-wallet/.gitignore create mode 100644 contracts/smart-wallet/build.rs create mode 100644 contracts/smart-wallet/src/tests/mod.rs delete mode 100644 contracts/smart-wallet/src/tests/test.rs create mode 100644 contracts/smart-wallet/src/tests/test_admin.rs create mode 100644 contracts/smart-wallet/src/tests/test_auth.rs create mode 100644 contracts/smart-wallet/src/tests/test_base64_url.rs create mode 100644 contracts/smart-wallet/src/tests/test_common.rs create mode 100644 contracts/smart-wallet/src/tests/test_deploy.rs delete mode 100644 contracts/smart-wallet/src/tests/test_extra.rs create mode 100644 contracts/smart-wallet/src/tests/test_integration.rs create mode 100644 contracts/smart-wallet/src/tests/test_webauthn.rs diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 6a60c92..43cbbb0 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "ahash" version = "0.8.12" @@ -201,38 +186,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -251,12 +210,6 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" -[[package]] -name = "bitflags" -version = "2.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" - [[package]] name = "block-buffer" version = "0.10.4" @@ -278,12 +231,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - [[package]] name = "bytes-lit" version = "0.0.6" @@ -491,6 +438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -565,6 +513,7 @@ dependencies = [ "elliptic-curve", "rfc6979", "signature", + "spki", ] [[package]] @@ -622,6 +571,8 @@ dependencies = [ "ff", "generic-array", "group", + "pem-rfc7468", + "pkcs8", "rand_core", "sec1", "subtle", @@ -720,12 +671,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "group" version = "0.13.0" @@ -859,17 +804,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags", - "cfg-if", - "libc", -] - [[package]] name = "itertools" version = "0.13.0" @@ -951,26 +885,6 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] - [[package]] name = "num-bigint" version = "0.4.6" @@ -1016,15 +930,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -1050,10 +955,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "pem-rfc7468" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] [[package]] name = "pkcs8" @@ -1177,12 +1085,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc_version" version = "0.4.1" @@ -1256,6 +1158,7 @@ dependencies = [ "base16ct", "der", "generic-array", + "pkcs8", "subtle", "zeroize", ] @@ -1352,19 +1255,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha256" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f880fc8562bdeb709793f00eb42a2ad0e672c4f883bbe59122b926eca935c8f6" -dependencies = [ - "async-trait", - "bytes", - "hex", - "sha2", - "tokio", -] - [[package]] name = "sha3" version = "0.10.8" @@ -1391,12 +1281,6 @@ dependencies = [ "rand_core", ] -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - [[package]] name = "smallvec" version = "1.15.1" @@ -1410,10 +1294,11 @@ dependencies = [ "base64", "ed25519-dalek", "example-contract", + "p256", "sample-policy", "serde", "serde-json-core", - "sha256", + "sha2", "smart-wallet-interface", "soroban-sdk", "stellar-strkey 0.0.13", @@ -1761,21 +1646,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tokio" -version = "1.47.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" -dependencies = [ - "backtrace", - "bytes", - "io-uring", - "libc", - "mio", - "pin-project-lite", - "slab", -] - [[package]] name = "typenum" version = "1.18.0" @@ -1965,79 +1835,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "zerocopy" version = "0.8.26" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index e2fffba..b10e813 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -14,8 +14,9 @@ publish = false soroban-sdk = "27.0.0" serde = { version = "1.0", default-features = false } serde-json-core = { version = "0.6", default-features = false } -sha256 = { version = "1.6" } ed25519-dalek = { version = "2.2" } +p256 = { version = "0.13", features = ["ecdsa"] } +sha2 = "0.10" base64 = "0.22" stellar-strkey = { version = "0.0.13" } smart-wallet = { path = "smart-wallet" } diff --git a/contracts/Makefile b/contracts/Makefile index f9c0cef..452225c 100644 --- a/contracts/Makefile +++ b/contracts/Makefile @@ -13,9 +13,9 @@ build: rm -rf out/ stellar contract build --package smart-wallet --out-dir out -# Tests import sample_policy.wasm (cross-contract fixture), so build it first. +# Self-sufficient: smart-wallet's build.rs builds the wasm fixtures tests +# import (native builds only — contract builds skip it). test: - stellar contract build --package sample-policy cargo test upload: diff --git a/contracts/smart-wallet/.gitignore b/contracts/smart-wallet/.gitignore new file mode 100644 index 0000000..67fc140 --- /dev/null +++ b/contracts/smart-wallet/.gitignore @@ -0,0 +1 @@ +fixtures/ diff --git a/contracts/smart-wallet/Cargo.toml b/contracts/smart-wallet/Cargo.toml index e8f80f4..b14661d 100644 --- a/contracts/smart-wallet/Cargo.toml +++ b/contracts/smart-wallet/Cargo.toml @@ -23,7 +23,8 @@ serde-json-core = { workspace = true } soroban-sdk = { workspace = true, features = ["testutils"] } stellar-strkey = { workspace = true } ed25519-dalek = { workspace = true } +p256 = { workspace = true } +sha2 = { workspace = true } base64 = { workspace = true } -sha256 = { workspace = true } sample-policy = { workspace = true, features = ["testutils"] } example-contract = { workspace = true, features = ["testutils"] } diff --git a/contracts/smart-wallet/build.rs b/contracts/smart-wallet/build.rs new file mode 100644 index 0000000..0cc5ff1 --- /dev/null +++ b/contracts/smart-wallet/build.rs @@ -0,0 +1,51 @@ +//! Builds the wasm fixtures the test suite imports (sample_policy.wasm for +//! cross-contract policy tests and the deploy path; smart_wallet.wasm for the +//! upgrade path), so a clean-checkout `cargo test` is self-sufficient. +//! +//! Only runs for native builds: contract (wasm) builds skip it entirely, so +//! the canonical wasm artifact is never affected by this script. + +use std::{env, path::PathBuf, process::Command}; + +fn main() { + let target = env::var("TARGET").unwrap_or_default(); + if target.starts_with("wasm32") { + return; + } + + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let fixture_target = out_dir.join("fixture-target"); + let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + + let status = Command::new(&cargo) + .args([ + "build", + "--package", + "sample-policy", + "--package", + "smart-wallet", + "--target", + "wasm32v1-none", + "--release", + "--locked", + ]) + .env("CARGO_TARGET_DIR", &fixture_target) + .status() + .expect("failed to spawn cargo to build test fixture wasms"); + assert!(status.success(), "building test fixture wasms failed"); + + // contractimport! only accepts literal paths (resolved relative to the + // crate manifest), so surface the artifacts at a stable, gitignored + // location the test imports can name. + let release = fixture_target.join("wasm32v1-none").join("release"); + let fixtures = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("fixtures"); + std::fs::create_dir_all(&fixtures).unwrap(); + for wasm in ["sample_policy.wasm", "smart_wallet.wasm"] { + std::fs::copy(release.join(wasm), fixtures.join(wasm)) + .unwrap_or_else(|e| panic!("copying fixture {wasm}: {e}")); + } + + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=../sample-policy/src"); + println!("cargo:rerun-if-changed=../smart-wallet-interface/src"); +} diff --git a/contracts/smart-wallet/src/lib.rs b/contracts/smart-wallet/src/lib.rs index 88f762a..83cb309 100644 --- a/contracts/smart-wallet/src/lib.rs +++ b/contracts/smart-wallet/src/lib.rs @@ -25,10 +25,8 @@ mod signer; mod storage; mod verify; -#[path = "./tests/test.rs"] -mod test; -#[path = "./tests/test_extra.rs"] -mod test_extra; +#[cfg(test)] +mod tests; contractmeta!(key = "binver", val = env!("CARGO_PKG_VERSION")); diff --git a/contracts/smart-wallet/src/tests/mod.rs b/contracts/smart-wallet/src/tests/mod.rs new file mode 100644 index 0000000..cb385d0 --- /dev/null +++ b/contracts/smart-wallet/src/tests/mod.rs @@ -0,0 +1,10 @@ +#![cfg(test)] + +pub mod test_common; + +mod test_admin; +mod test_auth; +mod test_base64_url; +mod test_deploy; +mod test_integration; +mod test_webauthn; diff --git a/contracts/smart-wallet/src/tests/test.rs b/contracts/smart-wallet/src/tests/test.rs deleted file mode 100644 index f23c31e..0000000 --- a/contracts/smart-wallet/src/tests/test.rs +++ /dev/null @@ -1,329 +0,0 @@ -#![cfg(test)] - -use std::println; -extern crate std; - -use crate::{Contract, ContractClient}; -use ed25519_dalek::{Signer as _, SigningKey}; -use example_contract::{Contract as ExampleContract, ContractClient as ExampleContractClient}; -use sample_policy::Contract as PolicyContract; -use smart_wallet_interface::types::{ - Signature, Signatures, Signer, SignerExpiration, SignerKey, SignerLimits, SignerStorage, -}; -use soroban_sdk::{ - map, - testutils::EnvTestConfig, - token, vec, - xdr::{ - HashIdPreimage, HashIdPreimageSorobanAuthorization, InvokeContractArgs, Limits, - SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanAuthorizedFunction, - SorobanAuthorizedInvocation, SorobanCredentials, ToXdr, VecM, WriteXdr, - }, - Address, Bytes, BytesN, Env, String, -}; -use stellar_strkey::{ed25519, Strkey}; - -#[test] -fn test() { - let mut env: Env = Env::default(); - - env.set_config(EnvTestConfig { - capture_snapshot_at_drop: false, - }); - - let signature_expiration_ledger = env.ledger().sequence(); - let amount = 10_000_000i128; - let evil_amount = 10_000_00i128; - - // Super Ed25519 - let super_ed25519_keypair = SigningKey::from_keypair_bytes(&[ - 88, 206, 67, 128, 240, 45, 168, 148, 191, 111, 180, 111, 104, 83, 214, 113, 78, 27, 55, 86, - 200, 247, 164, 163, 76, 236, 24, 208, 115, 40, 231, 255, 161, 115, 141, 114, 97, 125, 136, - 247, 117, 105, 60, 155, 144, 51, 216, 187, 185, 157, 18, 126, 169, 172, 15, 4, 148, 13, - 208, 144, 53, 12, 91, 78, - ]) - .unwrap(); - - let super_ed25519_strkey = Strkey::PublicKeyEd25519(ed25519::PublicKey( - super_ed25519_keypair.verifying_key().to_bytes(), - )); - let super_ed25519 = Bytes::from_slice(&env, super_ed25519_strkey.to_string().as_bytes()); - let super_ed25519 = Address::from_string_bytes(&super_ed25519); - - let super_ed25519_bytes = super_ed25519.to_xdr(&env); - let super_ed25519_bytes = super_ed25519_bytes.slice(super_ed25519_bytes.len() - 32..); - let mut super_ed25519_array = [0u8; 32]; - super_ed25519_bytes.copy_into_slice(&mut super_ed25519_array); - let super_ed25519_bytes = BytesN::from_array(&env, &super_ed25519_array); - - let _super_ed25519_signer_key = SignerKey::Ed25519(super_ed25519_bytes.clone()); - // - - let wallet_address = env.register( - Contract, - (Signer::Ed25519( - super_ed25519_bytes, - SignerExpiration(None), - SignerLimits(None), - SignerStorage::Persistent, - ),), - ); - let wallet_client = ContractClient::new(&env, &wallet_address); - - let example_contract_address = env.register(ExampleContract, ()); - let example_contract_client = ExampleContractClient::new(&env, &example_contract_address); - - // SAC - let sac_admin = Address::from_string(&String::from_str( - &env, - "GD7777777777777777777777777777777777777777777777777773DB", - )); - let sac = env.register_stellar_asset_contract_v2(sac_admin); - let sac_address = sac.address(); - let sac_admin_client = token::StellarAssetClient::new(&env, &sac_address); - let _sac_client = token::Client::new(&env, &sac_address); - // - - sac_admin_client - .mock_all_auths() - .mint(&wallet_address, &100_000_000); - - // Secp256r1 - // let secp256r1_id = Bytes::from_array( - // &env, - // &[ - // 243, 248, 216, 74, 226, 218, 85, 102, 196, 167, 14, 151, 124, 42, 73, 136, 138, 102, - // 187, 140, - // ], - // ); - // let secp256r1_public_key = BytesN::from_array( - // &env, - // &[ - // 4, 163, 142, 245, 242, 113, 55, 104, 189, 52, 128, 238, 206, 174, 194, 177, 4, 100, - // 161, 243, 177, 255, 10, 53, 57, 194, 205, 45, 208, 10, 131, 167, 93, 44, 123, 126, 95, - // 219, 207, 230, 175, 90, 96, 41, 121, 197, 127, 180, 74, 236, 160, 0, 60, 185, 211, 174, - // 133, 215, 200, 208, 230, 51, 210, 94, 214, - // ], - // ); - // let secp256r1_signer_key = SignerKey::Secp256r1(secp256r1_id.clone()); - // let secp246r1_signer = Signer::Secp256r1( - // secp256r1_id, - // secp256r1_public_key, - // SignerLimits(map![&env]), - // SignerStorage::Temporary, - // ); - //// - - // Simple Ed25519 - let simple_ed25519_keypair = SigningKey::from_keypair_bytes(&[ - 149, 154, 40, 132, 13, 234, 167, 87, 182, 44, 152, 45, 242, 179, 187, 17, 139, 106, 49, 85, - 249, 235, 17, 248, 24, 170, 19, 164, 23, 117, 145, 252, 172, 35, 170, 26, 69, 15, 75, 127, - 192, 170, 166, 54, 68, 127, 218, 29, 130, 173, 159, 1, 253, 192, 48, 242, 80, 12, 55, 152, - 223, 122, 198, 96, - ]) - .unwrap(); - - let simple_ed25519_strkey = Strkey::PublicKeyEd25519(ed25519::PublicKey( - simple_ed25519_keypair.verifying_key().to_bytes(), - )); - let simple_ed25519_address = - Bytes::from_slice(&env, simple_ed25519_strkey.to_string().as_bytes()); - let simple_ed25519_address = Address::from_string_bytes(&simple_ed25519_address); - - let simple_ed25519_bytes = simple_ed25519_address.to_xdr(&env); - let simple_ed25519_bytes = simple_ed25519_bytes.slice(simple_ed25519_bytes.len() - 32..); - let mut simple_ed25519_array = [0u8; 32]; - simple_ed25519_bytes.copy_into_slice(&mut simple_ed25519_array); - let simple_ed25519_bytes = BytesN::from_array(&env, &simple_ed25519_array); - let simple_ed25519_signer_key = SignerKey::Ed25519(simple_ed25519_bytes.clone()); - // - - // Policy - let sample_policy_address = env.register(PolicyContract, ()); - let sample_policy_signer_key = SignerKey::Policy(sample_policy_address.clone()); - // - - // Add signers to smart wallet - // wallet_client.mock_all_auths().add(&Signer::Ed25519( - // super_ed25519_bytes, - // SignerLimits(map![&env]), - // SignerStorage::Persistent, - // )); - - // wallet_client - // .mock_all_auths() - // .add(&secp246r1_signer.clone()); - - wallet_client.mock_all_auths().add_signer(&Signer::Ed25519( - simple_ed25519_bytes, - SignerExpiration(None), - SignerLimits(Some(map![ - &env, - ( - sac_address.clone(), - Some(vec![&env, sample_policy_signer_key.clone()]) - ), - (example_contract_address.clone(), None,) - ])), - SignerStorage::Temporary, - )); - - wallet_client.mock_all_auths().add_signer(&Signer::Policy( - sample_policy_address.clone(), - SignerExpiration(None), - SignerLimits(Some(map![ - &env, - ( - sac_address.clone(), - Some(vec![&env, simple_ed25519_signer_key.clone()]) - ), - ])), - SignerStorage::Temporary, - )); - // - - let transfer_invocation = SorobanAuthorizedInvocation { - function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { - contract_address: sac_address.clone().try_into().unwrap(), - function_name: "transfer".try_into().unwrap(), - args: std::vec![ - wallet_address.clone().try_into().unwrap(), - sac_address.clone().try_into().unwrap(), - amount.try_into().unwrap(), - ] - .try_into() - .unwrap(), - }), - sub_invocations: VecM::default(), - }; - - let evil_transfer_invocation = SorobanAuthorizedInvocation { - function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { - contract_address: sac_address.clone().try_into().unwrap(), - function_name: "transfer".try_into().unwrap(), - args: std::vec![ - wallet_address.clone().try_into().unwrap(), - sac_address.clone().try_into().unwrap(), - evil_amount.try_into().unwrap(), - ] - .try_into() - .unwrap(), - }), - sub_invocations: VecM::default(), - }; - - // let remove_invocation = SorobanAuthorizedInvocation { - // function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { - // contract_address: wallet_address.clone().try_into().unwrap(), - // function_name: "remove".try_into().unwrap(), - // args: std::vec![secp256r1_signer_key.clone().try_into().unwrap(),] - // .try_into() - // .unwrap(), - // }), - // sub_invocations: VecM::default(), - // }; - - // let add_invocation = SorobanAuthorizedInvocation { - // function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { - // contract_address: wallet_address.clone().try_into().unwrap(), - // function_name: "add".try_into().unwrap(), - // args: std::vec![secp246r1_signer.clone().try_into().unwrap(),] - // .try_into() - // .unwrap(), - // }), - // sub_invocations: VecM::default(), - // }; - - let root_invocation = SorobanAuthorizedInvocation { - function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { - contract_address: example_contract_address.clone().try_into().unwrap(), - function_name: "call".try_into().unwrap(), - args: std::vec![ - sac_address.clone().try_into().unwrap(), - wallet_address.clone().try_into().unwrap(), - sac_address.clone().try_into().unwrap(), - amount.try_into().unwrap(), - // remove_key.clone().try_into().unwrap(), - // add_signer.clone().try_into().unwrap(), - ] - .try_into() - .unwrap(), - }), - sub_invocations: std::vec![ - transfer_invocation.clone(), - evil_transfer_invocation.clone(), - // remove_invocation.clone(), - // add_invocation.clone(), - ] - .try_into() - .unwrap(), - }; - - let payload = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { - network_id: env.ledger().network_id().to_array().into(), - nonce: 3, - signature_expiration_ledger, - invocation: root_invocation.clone(), - }); - let payload = payload.to_xdr(Limits::none()).unwrap(); - let payload = Bytes::from_slice(&env, payload.as_slice()); - let payload = env.crypto().sha256(&payload); - - // let super_ed25519_signature = Signature::Ed25519(BytesN::from_array( - // &env, - // &super_ed25519_keypair - // .sign(payload.to_array().as_slice()) - // .to_bytes(), - // )); - - let simple_ed25519_signature = Signature::Ed25519(BytesN::from_array( - &env, - &simple_ed25519_keypair - .sign(payload.to_array().as_slice()) - .to_bytes(), - )); - - let root_auth = SorobanAuthorizationEntry { - credentials: SorobanCredentials::Address(SorobanAddressCredentials { - address: wallet_address.clone().try_into().unwrap(), - nonce: 3, - signature_expiration_ledger, - signature: Signatures(map![ - &env, - (simple_ed25519_signer_key.clone(), simple_ed25519_signature), - // ( - // sample_policy_signer_key.clone(), - // None - // ), - // ( - // super_ed25519_signer_key.clone(), - // Some(super_ed25519_signature) - // ), - ]) - .try_into() - .unwrap(), - }), - root_invocation: root_invocation.clone(), - }; - - env.cost_estimate().budget().reset_default(); - - example_contract_client.set_auths(&[root_auth]).call( - &sac_address, - &wallet_address, - &sac_address, - &amount, - // &remove_key, - // &add_signer, - ); - - // Loose - // Cpu limit: 100000000; used: 1011983 - // Mem limit: 41943040; used: 106096 - - // Careful - // Cpu limit: 100000000; used: 999991 - // Mem limit: 41943040; used: 86036 - - println!("{:?}", env.cost_estimate().budget().print()); -} diff --git a/contracts/smart-wallet/src/tests/test_admin.rs b/contracts/smart-wallet/src/tests/test_admin.rs new file mode 100644 index 0000000..f57cd04 --- /dev/null +++ b/contracts/smart-wallet/src/tests/test_admin.rs @@ -0,0 +1,698 @@ +#![cfg(test)] +//! Signer management: add/update/remove/upgrade/get_signer, event emission, +//! durability moves, and the policy install/uninstall lifecycle. + +extern crate std; + +use smart_wallet_interface::{ + events::{SignerAdded, SignerRemoved, SignerUpdated, Upgraded}, + types::{Error, Signer, SignerExpiration, SignerKey, SignerLimits, SignerStorage, SignerVal}, + PolicyInterface, +}; +use soroban_sdk::{ + auth::Context, + contract, contractimpl, contracttype, + testutils::{Events as _, Ledger as _}, + vec, Address, Bytes, Env, Event as _, Vec, +}; + +use crate::tests::test_common::*; + +mod smart_wallet_wasm { + use soroban_sdk::auth::Context; + soroban_sdk::contractimport!(file = "fixtures/smart_wallet.wasm"); +} + +/// Policy that records install/uninstall state per wallet (the pattern +/// stateful policies must follow) and allows everything. +#[contract] +pub struct LifecyclePolicy; + +#[contracttype] +pub enum LifecycleKey { + Installed(Address), +} + +#[contractimpl] +impl PolicyInterface for LifecyclePolicy { + fn policy__(_env: Env, _source: Address, _signer: SignerKey, _contexts: Vec) {} + + fn install(env: Env, wallet: Address) { + wallet.require_auth(); + env.storage() + .persistent() + .set(&LifecycleKey::Installed(wallet), &true); + } + + fn uninstall(env: Env, wallet: Address) { + wallet.require_auth(); + env.storage() + .persistent() + .remove(&LifecycleKey::Installed(wallet)); + } +} + +/// Policy whose install always panics: adding it must fail. +#[contract] +pub struct RejectingPolicy; + +#[contractimpl] +impl PolicyInterface for RejectingPolicy { + fn policy__(_env: Env, _source: Address, _signer: SignerKey, _contexts: Vec) {} + + fn install(_env: Env, _wallet: Address) { + panic!("install rejected"); + } + + fn uninstall(_env: Env, _wallet: Address) {} +} + +/// Policy whose uninstall always panics: removal must still succeed. +#[contract] +pub struct StickyPolicy; + +#[contractimpl] +impl PolicyInterface for StickyPolicy { + fn policy__(_env: Env, _source: Address, _signer: SignerKey, _contexts: Vec) {} + + fn install(env: Env, wallet: Address) { + wallet.require_auth(); + let _ = env; + } + + fn uninstall(_env: Env, _wallet: Address) { + panic!("uninstall rejected"); + } +} + +fn is_installed(env: &Env, policy: &Address, wallet: &Address) -> bool { + env.as_contract(policy, || { + env.storage() + .persistent() + .has(&LifecycleKey::Installed(wallet.clone())) + }) +} + +fn has_entry(env: &Env, wallet: &Address, key: &SignerKey, storage: &SignerStorage) -> bool { + env.as_contract(wallet, || match storage { + SignerStorage::Persistent => env.storage().persistent().has::(key), + SignerStorage::Temporary => env.storage().temporary().has::(key), + }) +} + +// --- Constructor ----------------------------------------------------------- + +#[test] +fn constructor_stores_signer_and_emits_event() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let signer = a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ); + + let (wallet, client) = register_wallet(&env, &signer); + + // events().all() only surfaces the LAST invocation's events — assert the + // constructor's event before any other call. + let expected = SignerAdded { + key: a.signer_key(&env), + val: SignerVal::Ed25519(SignerExpiration(None), SignerLimits(None)), + storage: SignerStorage::Persistent, + }; + assert_eq!( + env.events().all(), + vec![ + &env, + (wallet.clone(), expected.topics(&env), expected.data(&env)) + ] + ); + + assert_eq!( + client.get_signer(&a.signer_key(&env)), + Some(SignerVal::Ed25519( + SignerExpiration(None), + SignerLimits(None) + )) + ); +} + +// --- add_signer -------------------------------------------------------------- + +#[test] +fn add_signer_requires_auth() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + + let (_, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + // No auth mocked: must fail. + assert!(client + .try_add_signer(&b.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + )) + .is_err()); +} + +#[test] +fn add_signer_duplicate_fails() { + let env = test_env(); + let a = Ed25519Signer::new(1); + + let (_, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + assert_eq!( + client.mock_all_auths().try_add_signer(&a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Temporary, + )), + Err(Ok(Error::SignerAlreadyExists)) + ); +} + +#[test] +fn add_signer_emits_event() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let passkey = Passkey::new(2); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + client.mock_all_auths().add_signer(&passkey.signer( + &env, + SignerExpiration(Some(123_456)), + SignerLimits(None), + SignerStorage::Temporary, + )); + + let expected = SignerAdded { + key: passkey.signer_key(&env), + val: SignerVal::Secp256r1( + passkey.public_key(&env), + SignerExpiration(Some(123_456)), + SignerLimits(None), + ), + storage: SignerStorage::Temporary, + }; + assert_eq!( + env.events().all(), + vec![ + &env, + (wallet.clone(), expected.topics(&env), expected.data(&env)) + ] + ); +} + +// --- update_signer ------------------------------------------------------------ + +#[test] +fn update_signer_nonexistent_fails() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + + let (_, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + assert_eq!( + client.mock_all_auths().try_update_signer(&b.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + )), + Err(Ok(Error::SignerNotFound)) + ); +} + +/// A durability flip must remove the entry from the old durability (the "at +/// most one entry per key" invariant) and report the old storage in the event. +#[test] +fn update_signer_durability_flip() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let key = a.signer_key(&env); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + assert!(has_entry(&env, &wallet, &key, &SignerStorage::Persistent)); + + client.mock_all_auths().update_signer(&a.signer( + &env, + SignerExpiration(Some(9_999)), + SignerLimits(None), + SignerStorage::Temporary, + )); + + let expected = SignerUpdated { + key: key.clone(), + val: SignerVal::Ed25519(SignerExpiration(Some(9_999)), SignerLimits(None)), + storage: SignerStorage::Temporary, + old_storage: SignerStorage::Persistent, + }; + assert_eq!( + env.events().all(), + vec![ + &env, + (wallet.clone(), expected.topics(&env), expected.data(&env)) + ] + ); + + assert!(!has_entry(&env, &wallet, &key, &SignerStorage::Persistent)); + assert!(has_entry(&env, &wallet, &key, &SignerStorage::Temporary)); + + assert_eq!( + client.get_signer(&key), + Some(SignerVal::Ed25519( + SignerExpiration(Some(9_999)), + SignerLimits(None) + )) + ); +} + +#[test] +fn update_signer_same_durability() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let key = a.signer_key(&env); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + client.mock_all_auths().update_signer(&a.signer( + &env, + SignerExpiration(Some(1_000)), + SignerLimits(None), + SignerStorage::Persistent, + )); + + let expected = SignerUpdated { + key: key.clone(), + val: SignerVal::Ed25519(SignerExpiration(Some(1_000)), SignerLimits(None)), + storage: SignerStorage::Persistent, + old_storage: SignerStorage::Persistent, + }; + assert_eq!( + env.events().all(), + vec![ + &env, + (wallet.clone(), expected.topics(&env), expected.data(&env)) + ] + ); + + assert!(has_entry(&env, &wallet, &key, &SignerStorage::Persistent)); +} + +// --- remove_signer --------------------------------------------------------------- + +#[test] +fn remove_signer_nonexistent_fails() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + + let (_, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + assert_eq!( + client + .mock_all_auths() + .try_remove_signer(&b.signer_key(&env)), + Err(Ok(Error::SignerNotFound)) + ); +} + +#[test] +fn remove_signer_removes_and_emits_event() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + let key = b.signer_key(&env); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + client.mock_all_auths().add_signer(&b.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Temporary, + )); + assert!(has_entry(&env, &wallet, &key, &SignerStorage::Temporary)); + + client.mock_all_auths().remove_signer(&key); + + let expected = SignerRemoved { + key: key.clone(), + storage: SignerStorage::Temporary, + }; + assert_eq!( + env.events().all(), + vec![ + &env, + (wallet.clone(), expected.topics(&env), expected.data(&env)) + ] + ); + + assert!(!has_entry(&env, &wallet, &key, &SignerStorage::Temporary)); + assert_eq!(client.get_signer(&key), None); +} + +// --- get_signer ------------------------------------------------------------------- + +#[test] +fn get_signer_returns_raw_value_even_expired() { + let env = test_env(); + env.ledger().set_timestamp(1_000_000); + + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + + let (_, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + // Long expired — get_signer is a raw storage view, not an auth check. + client.mock_all_auths().add_signer(&b.signer( + &env, + SignerExpiration(Some(1)), + SignerLimits(None), + SignerStorage::Persistent, + )); + + assert_eq!( + client.get_signer(&b.signer_key(&env)), + Some(SignerVal::Ed25519( + SignerExpiration(Some(1)), + SignerLimits(None) + )) + ); + assert_eq!( + client.get_signer(&Ed25519Signer::new(9).signer_key(&env)), + None + ); +} + +// --- upgrade ----------------------------------------------------------------------- + +#[test] +fn upgrade_emits_event_and_tracks_hash() { + let env = test_env(); + let a = Ed25519Signer::new(1); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + let wasm_hash = env.deployer().upload_contract_wasm(smart_wallet_wasm::WASM); + + // First upgrade: no cached previous hash — old_hash is None. + client.mock_all_auths().upgrade(&wasm_hash); + + let expected = Upgraded { + old_hash: None, + new_hash: wasm_hash.clone(), + }; + assert_eq!( + env.events().all(), + vec![ + &env, + (wallet.clone(), expected.topics(&env), expected.data(&env)) + ] + ); + + // Second upgrade (now executing the wasm build of this same contract): + // the previous hash is known. + client.mock_all_auths().upgrade(&wasm_hash); + + let expected = Upgraded { + old_hash: Some(wasm_hash.clone()), + new_hash: wasm_hash.clone(), + }; + assert_eq!( + env.events().all(), + vec![ + &env, + (wallet.clone(), expected.topics(&env), expected.data(&env)) + ] + ); + + // The wallet still works after upgrading (same source, wasm-executed). + assert_eq!( + client.get_signer(&a.signer_key(&env)), + Some(SignerVal::Ed25519( + SignerExpiration(None), + SignerLimits(None) + )) + ); +} + +#[test] +fn upgrade_requires_auth() { + let env = test_env(); + let a = Ed25519Signer::new(1); + + let (_, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + let wasm_hash = env.deployer().upload_contract_wasm(smart_wallet_wasm::WASM); + + assert!(client.try_upgrade(&wasm_hash).is_err()); +} + +// --- Policy lifecycle (F6) -------------------------------------------------------- + +#[test] +fn policy_install_uninstall_lifecycle() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let policy = env.register(LifecyclePolicy, ()); + let policy_key = SignerKey::Policy(policy.clone()); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + assert!(!is_installed(&env, &policy, &wallet)); + + client.mock_all_auths().add_signer(&Signer::Policy( + policy.clone(), + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + )); + assert!(is_installed(&env, &policy, &wallet)); + + client.mock_all_auths().remove_signer(&policy_key); + assert!(!is_installed(&env, &policy, &wallet)); + assert_eq!(client.get_signer(&policy_key), None); +} + +/// A policy signer can also be the FIRST signer: the constructor runs the +/// install hook too. +#[test] +fn constructor_installs_policy_signer() { + let env = test_env(); + let policy_contract = env.register(LifecyclePolicy, ()); + + let (wallet, _) = register_wallet( + &env, + &Signer::Policy( + policy_contract.clone(), + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + assert!(is_installed(&env, &policy_contract, &wallet)); +} + +/// A policy that rejects install cannot be added as a signer. +#[test] +fn rejecting_policy_cannot_be_added() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let policy = env.register(RejectingPolicy, ()); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + assert!(client + .mock_all_auths() + .try_add_signer(&Signer::Policy( + policy.clone(), + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + )) + .is_err()); + + // The failed add left no state behind. + assert_eq!(client.get_signer(&SignerKey::Policy(policy.clone())), None); + assert!(!has_entry( + &env, + &wallet, + &SignerKey::Policy(policy), + &SignerStorage::Persistent + )); +} + +/// A policy whose uninstall panics must NOT be able to block its own removal. +#[test] +fn sticky_policy_can_still_be_removed() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let policy = env.register(StickyPolicy, ()); + let policy_key = SignerKey::Policy(policy.clone()); + + let (_, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + client.mock_all_auths().add_signer(&Signer::Policy( + policy.clone(), + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + )); + + client.mock_all_auths().remove_signer(&policy_key); + assert_eq!(client.get_signer(&policy_key), None); +} + +// --- Secp256r1 signer shape --------------------------------------------------- + +#[test] +fn secp256r1_signer_roundtrip() { + let env = test_env(); + let passkey = Passkey::new(5); + + let (_, client) = register_wallet( + &env, + &passkey.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Temporary, + ), + ); + + assert_eq!( + client.get_signer(&SignerKey::Secp256r1(Bytes::from_slice( + &env, + &passkey.key_id + ))), + Some(SignerVal::Secp256r1( + passkey.public_key(&env), + SignerExpiration(None), + SignerLimits(None) + )) + ); +} diff --git a/contracts/smart-wallet/src/tests/test_auth.rs b/contracts/smart-wallet/src/tests/test_auth.rs new file mode 100644 index 0000000..7723362 --- /dev/null +++ b/contracts/smart-wallet/src/tests/test_auth.rs @@ -0,0 +1,1083 @@ +#![cfg(test)] +//! `__check_auth` semantics: signer limits branches, expiration boundaries, +//! multi-sig requirements, self-removal, deploy permission, policy +//! granularity, and the recursion depth guard. + +extern crate std; + +use smart_wallet_interface::{ + types::{ + Error, Signature, Signatures, Signer, SignerExpiration, SignerKey, SignerLimits, + SignerStorage, + }, + PolicyInterface, +}; +use soroban_sdk::{ + auth::{Context, ContractExecutable, CreateContractHostFnContext}, + contract, contractimpl, map, symbol_short, + testutils::{Address as _, Ledger as _}, + vec, Address, Env, IntoVal, InvokeError, Val, Vec, +}; + +use crate::tests::test_common::*; + +/// Test policy that allows everything and counts `policy__` invocations in +/// its instance storage, to observe invocation granularity. +#[contract] +pub struct CountingPolicy; + +#[contractimpl] +impl PolicyInterface for CountingPolicy { + fn policy__(env: Env, _source: Address, _signer: SignerKey, _contexts: Vec) { + let count: u32 = env + .storage() + .instance() + .get(&symbol_short!("count")) + .unwrap_or(0); + env.storage() + .instance() + .set(&symbol_short!("count"), &(count + 1)); + } + + fn install(env: Env, wallet: Address) { + wallet.require_auth(); + let _ = env; + } + + fn uninstall(env: Env, wallet: Address) { + wallet.require_auth(); + let _ = env; + } +} + +fn policy_count(env: &Env, policy: &Address) -> u32 { + env.as_contract(policy, || { + env.storage() + .instance() + .get(&symbol_short!("count")) + .unwrap_or(0) + }) +} + +fn check_auth( + env: &Env, + wallet: &Address, + payload: &soroban_sdk::BytesN<32>, + signatures: Signatures, + contexts: &Vec, +) -> Result<(), Result> { + env.try_invoke_contract_check_auth::(wallet, payload, signatures.into_val(env), contexts) +} + +fn no_limits() -> SignerLimits { + SignerLimits(None) +} + +fn empty_limits(env: &Env) -> SignerLimits { + SignerLimits(Some(map![env])) +} + +fn contract_limits(env: &Env, contract: &Address, keys: Option>) -> SignerLimits { + SignerLimits(Some(map![env, (contract.clone(), keys)])) +} + +// --- Basic coverage ------------------------------------------------------ + +#[test] +fn unlimited_signer_authorizes_anything() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Ok(()) + ); +} + +/// F9: `Some(empty map)` is fail-closed — NO permissions. +#[test] +fn empty_limits_map_authorizes_nothing() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + empty_limits(&env), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Err(Ok(Error::MissingContext)) + ); +} + +#[test] +fn limited_signer_covers_only_granted_contract() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let token = Address::generate(&env); + let other = Address::generate(&env); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + contract_limits(&env, &token, None), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let signatures = Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]); + + // Granted contract: covered. + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + signatures.clone(), + &vec![&env, transfer_context(&env, &token, &wallet, 1)], + ), + Ok(()) + ); + + // Any other contract: not covered. + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + signatures.clone(), + &vec![&env, transfer_context(&env, &other, &wallet, 1)], + ), + Err(Ok(Error::MissingContext)) + ); + + // Multiple contexts where only one is covered: whole auth fails. + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + signatures, + &vec![ + &env, + transfer_context(&env, &token, &wallet, 1), + transfer_context(&env, &other, &wallet, 1), + ], + ), + Err(Ok(Error::MissingContext)) + ); +} + +// --- Signatures map hygiene (pass 2) ------------------------------------- + +#[test] +fn unknown_signer_alone_is_missing_context() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let stranger = Ed25519Signer::new(2); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + // Only a signature from a signer that is not stored on the wallet: + // nothing can cover the context. + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![ + &env, + (stranger.signer_key(&env), stranger.sign(&env, &payload)) + ]), + &contexts, + ), + Err(Ok(Error::MissingContext)) + ); +} + +/// Every signatures-map entry must be stored: an unknown extra signer fails +/// the whole auth even when another signer covers all contexts. +#[test] +fn unknown_extra_signer_fails_auth() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let stranger = Ed25519Signer::new(2); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![ + &env, + (a.signer_key(&env), a.sign(&env, &payload)), + (stranger.signer_key(&env), stranger.sign(&env, &payload)), + ]), + &contexts, + ), + Err(Ok(Error::SignerNotFound)) + ); +} + +#[test] +fn empty_signatures_map_is_missing_context() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + assert_eq!( + check_auth(&env, &wallet, &payload, Signatures(map![&env]), &contexts), + Err(Ok(Error::MissingContext)) + ); +} + +#[test] +fn invalid_ed25519_signature_fails_in_host() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + // b's signature submitted under a's key. + let result = check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), b.sign(&env, &payload))]), + &contexts, + ); + + assert!(result.is_err()); + assert!( + !matches!(result, Err(Ok(_))), + "must fail in host crypto, not a wallet-typed error: {result:?}" + ); +} + +/// `Signature::Policy` submitted for an Ed25519 signer key. +#[test] +fn policy_signature_for_ed25519_key_is_mismatch() { + let env = test_env(); + let a = Ed25519Signer::new(1); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), Signature::Policy)]), + &contexts, + ), + Err(Ok(Error::SignatureKeyValueMismatch)) + ); +} + +// --- Expiration (F2/F12) -------------------------------------------------- + +/// Expiration is inclusive: valid while `now <= exp`, expired once `now > exp`. +#[test] +fn expiration_boundary() { + let env = test_env(); + let expiration = 10_000u64; + let a = Ed25519Signer::new(1); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(Some(expiration)), + no_limits(), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + let signatures = Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]); + + // At exactly the expiration timestamp: still valid. + env.ledger().set_timestamp(expiration); + assert_eq!( + check_auth(&env, &wallet, &payload, signatures.clone(), &contexts), + Ok(()) + ); + + // One second past: expired. + env.ledger().set_timestamp(expiration + 1); + assert_eq!( + check_auth(&env, &wallet, &payload, signatures, &contexts), + Err(Ok(Error::SignerExpired)) + ); +} + +/// An expired EXTRA signer poisons the auth deterministically (pass 2 checks +/// every entry), regardless of whether it was needed for coverage. +#[test] +fn expired_extra_signer_fails_auth() { + let env = test_env(); + env.ledger().set_timestamp(1_000_000); + + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + // b expired long ago. + client.mock_all_auths().add_signer(&b.signer( + &env, + SignerExpiration(Some(1)), + no_limits(), + SignerStorage::Persistent, + )); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![ + &env, + (a.signer_key(&env), a.sign(&env, &payload)), + (b.signer_key(&env), b.sign(&env, &payload)), + ]), + &contexts, + ), + Err(Ok(Error::SignerExpired)) + ); +} + +// --- Multi-sig via limits (co-signers) ------------------------------------ + +fn multisig_setup(env: &Env) -> (Address, Ed25519Signer, Ed25519Signer, Address) { + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + let token = Address::generate(env); + + let (wallet, client) = register_wallet( + env, + &b.signer( + env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + // a may only touch `token`, and only together with b. + client.mock_all_auths().add_signer(&a.signer( + env, + SignerExpiration(None), + contract_limits(env, &token, Some(vec![env, b.signer_key(env)])), + SignerStorage::Persistent, + )); + + (wallet, a, b, token) +} + +#[test] +fn co_signer_present_authorizes() { + let env = test_env(); + let (wallet, a, b, token) = multisig_setup(&env); + + let payload = payload(&env, 7); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![ + &env, + (a.signer_key(&env), a.sign(&env, &payload)), + (b.signer_key(&env), b.sign(&env, &payload)), + ]), + &contexts, + ), + Ok(()) + ); +} + +#[test] +fn co_signer_missing_fails_coverage() { + let env = test_env(); + let (wallet, a, _b, token) = multisig_setup(&env); + + let payload = payload(&env, 7); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + // a alone: its limits require b in the signatures map. + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Err(Ok(Error::MissingContext)) + ); +} + +/// An expired co-signer satisfies pass-1 presence but fails pass 2: the F2 +/// hole (expired co-signer silently satisfying limits) is closed. +#[test] +fn expired_co_signer_fails_auth() { + let env = test_env(); + env.ledger().set_timestamp(1_000_000); + + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + let token = Address::generate(&env); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + contract_limits(&env, &token, Some(vec![&env, b.signer_key(&env)])), + SignerStorage::Persistent, + ), + ); + + client.mock_all_auths().add_signer(&b.signer( + &env, + SignerExpiration(Some(1)), + no_limits(), + SignerStorage::Persistent, + )); + + let payload = payload(&env, 7); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![ + &env, + (a.signer_key(&env), a.sign(&env, &payload)), + (b.signer_key(&env), b.sign(&env, &payload)), + ]), + &contexts, + ), + Err(Ok(Error::SignerExpired)) + ); +} + +/// Mutual co-signer requirements (2-of-2) work: non-policy required keys are +/// presence-checked, so there is no recursion. +#[test] +fn mutual_co_signers_authorize() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + let token = Address::generate(&env); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + contract_limits(&env, &token, Some(vec![&env, b.signer_key(&env)])), + SignerStorage::Persistent, + ), + ); + + client.mock_all_auths().add_signer(&b.signer( + &env, + SignerExpiration(None), + contract_limits(&env, &token, Some(vec![&env, a.signer_key(&env)])), + SignerStorage::Persistent, + )); + + let payload = payload(&env, 7); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![ + &env, + (a.signer_key(&env), a.sign(&env, &payload)), + (b.signer_key(&env), b.sign(&env, &payload)), + ]), + &contexts, + ), + Ok(()) + ); +} + +// --- Self-removal rule ----------------------------------------------------- + +/// A limited signer (even with an empty limits map) may always remove itself. +#[test] +fn limited_signer_can_always_self_remove() { + let env = test_env(); + let a = Ed25519Signer::new(1); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + empty_limits(&env), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let contexts = vec![ + &env, + remove_signer_context(&env, &wallet, &a.signer_key(&env)), + ]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Ok(()) + ); +} + +/// Removing a DIFFERENT key is not self-removal. +#[test] +fn limited_signer_cannot_remove_others() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + empty_limits(&env), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let contexts = vec![ + &env, + remove_signer_context(&env, &wallet, &b.signer_key(&env)), + ]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Err(Ok(Error::MissingContext)) + ); +} + +/// F5 regression: a foreign contract's `remove_signer` function (with +/// arbitrary argument types) must not trip the self-removal rule — the rule +/// is gated on the wallet's own address, and foreign args must never panic. +#[test] +fn foreign_remove_signer_fn_does_not_interfere() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let foreign = Address::generate(&env); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + contract_limits(&env, &foreign, None), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + // foreign.remove_signer(u32, u32) — not a SignerKey in sight. + let contexts = vec![ + &env, + contract_context( + &env, + &foreign, + "remove_signer", + vec![&env, 42u32.into_val(&env), 43u32.into_val(&env)], + ), + ]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Ok(()) + ); +} + +/// The wallet's own address in a limits map grants the admin surface — an +/// explicit, documented escalation grant. +#[test] +fn wallet_self_entry_grants_admin_functions() { + let env = test_env(); + let bootstrap = Ed25519Signer::new(9); + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + + // The wallet address is only known after registration, so bootstrap with + // an unlimited signer and then add the wallet-self-limited one. + let (wallet, client) = register_wallet( + &env, + &bootstrap.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + client.mock_all_auths().add_signer(&a.signer( + &env, + SignerExpiration(None), + contract_limits(&env, &wallet, None), + SignerStorage::Persistent, + )); + + let payload = payload(&env, 7); + let new_signer: Val = b + .signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ) + .into_val(&env); + let contexts = vec![ + &env, + contract_context(&env, &wallet, "add_signer", vec![&env, new_signer]), + ]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Ok(()) + ); +} + +// --- Deploy permission (F9 decoupling) ------------------------------------- + +fn create_contract_context(env: &Env) -> Context { + Context::CreateContractHostFn(CreateContractHostFnContext { + executable: ContractExecutable::Wasm(soroban_sdk::BytesN::from_array(env, &[9; 32])), + salt: soroban_sdk::BytesN::from_array(env, &[8; 32]), + }) +} + +#[test] +fn unlimited_signer_can_deploy() { + let env = test_env(); + let a = Ed25519Signer::new(1); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let contexts = vec![&env, create_contract_context(&env)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Ok(()) + ); +} + +/// Deploy permission is NOT grantable through limits — not even a wallet-self +/// entry (the pre-1.0 coupling) grants it. +#[test] +fn limited_signer_cannot_deploy() { + let env = test_env(); + let bootstrap = Ed25519Signer::new(9); + let a = Ed25519Signer::new(1); + + let (wallet, client) = register_wallet( + &env, + &bootstrap.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + // Even a wallet-self limits entry (the pre-1.0 deploy grant) does not + // grant deploy. + client.mock_all_auths().add_signer(&a.signer( + &env, + SignerExpiration(None), + contract_limits(&env, &wallet, None), + SignerStorage::Persistent, + )); + + let payload = payload(&env, 7); + let contexts = vec![&env, create_contract_context(&env)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Err(Ok(Error::MissingContext)) + ); +} + +// --- Policy invocation granularity ------------------------------------------ + +/// A policy used as a signature sees the full context list in ONE call. +#[test] +fn policy_as_signature_called_once_with_all_contexts() { + let env = test_env(); + let policy = env.register(CountingPolicy, ()); + let token = Address::generate(&env); + let a = Ed25519Signer::new(1); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + ), + ); + + client.mock_all_auths().add_signer(&Signer::Policy( + policy.clone(), + SignerExpiration(None), + no_limits(), + SignerStorage::Persistent, + )); + + let payload = payload(&env, 7); + let contexts = vec![ + &env, + transfer_context(&env, &token, &wallet, 1), + transfer_context(&env, &token, &wallet, 2), + ]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![ + &env, + (SignerKey::Policy(policy.clone()), Signature::Policy) + ]), + &contexts, + ), + Ok(()) + ); + + assert_eq!(policy_count(&env, &policy), 1); +} + +/// A policy used inside another signer's limits is invoked once PER covered +/// context. +#[test] +fn policy_as_limit_called_per_context() { + let env = test_env(); + let policy = env.register(CountingPolicy, ()); + let token = Address::generate(&env); + let a = Ed25519Signer::new(1); + + let (wallet, _) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + contract_limits( + &env, + &token, + Some(vec![&env, SignerKey::Policy(policy.clone())]), + ), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let contexts = vec![ + &env, + transfer_context(&env, &token, &wallet, 1), + transfer_context(&env, &token, &wallet, 2), + ]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Ok(()) + ); + + assert_eq!(policy_count(&env, &policy), 2); +} + +// --- Recursion depth guard (F10) --------------------------------------------- + +/// Two stored policies whose limits require each other would recurse +/// unboundedly without the depth guard; with it, coverage fails closed. +#[test] +fn cyclic_policy_limits_fail_closed() { + let env = test_env(); + let policy_a = env.register(CountingPolicy, ()); + let policy_b = env.register(CountingPolicy, ()); + let token = Address::generate(&env); + let s = Ed25519Signer::new(1); + + let (wallet, client) = register_wallet( + &env, + &s.signer( + &env, + SignerExpiration(None), + contract_limits( + &env, + &token, + Some(vec![&env, SignerKey::Policy(policy_a.clone())]), + ), + SignerStorage::Persistent, + ), + ); + + // policy_a requires policy_b for token, policy_b requires policy_a: cycle. + client.mock_all_auths().add_signer(&Signer::Policy( + policy_a.clone(), + SignerExpiration(None), + contract_limits( + &env, + &token, + Some(vec![&env, SignerKey::Policy(policy_b.clone())]), + ), + SignerStorage::Persistent, + )); + client.mock_all_auths().add_signer(&Signer::Policy( + policy_b.clone(), + SignerExpiration(None), + contract_limits( + &env, + &token, + Some(vec![&env, SignerKey::Policy(policy_a.clone())]), + ), + SignerStorage::Persistent, + )); + + let payload = payload(&env, 7); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + // Fails closed via the depth guard — no stack/budget blowup. + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (s.signer_key(&env), s.sign(&env, &payload))]), + &contexts, + ), + Err(Ok(Error::MissingContext)) + ); +} + +/// An expired stored policy referenced in limits rejects the candidate +/// (boolean, in pass 1 — such keys never reach pass 2). +#[test] +fn expired_stored_policy_limit_key_rejects() { + let env = test_env(); + env.ledger().set_timestamp(1_000_000); + + let policy = env.register(CountingPolicy, ()); + let token = Address::generate(&env); + let a = Ed25519Signer::new(1); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + contract_limits( + &env, + &token, + Some(vec![&env, SignerKey::Policy(policy.clone())]), + ), + SignerStorage::Persistent, + ), + ); + + // The policy itself is stored — and expired. + client.mock_all_auths().add_signer(&Signer::Policy( + policy.clone(), + SignerExpiration(Some(1)), + no_limits(), + SignerStorage::Persistent, + )); + + let payload = payload(&env, 7); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (a.signer_key(&env), a.sign(&env, &payload))]), + &contexts, + ), + Err(Ok(Error::MissingContext)) + ); +} diff --git a/contracts/smart-wallet/src/tests/test_base64_url.rs b/contracts/smart-wallet/src/tests/test_base64_url.rs new file mode 100644 index 0000000..2dddea0 --- /dev/null +++ b/contracts/smart-wallet/src/tests/test_base64_url.rs @@ -0,0 +1,67 @@ +#![cfg(test)] +//! Unit vectors for the no_std base64url (unpadded) encoder used to build +//! the expected WebAuthn challenge. + +extern crate std; + +use crate::base64_url; +use crate::tests::test_common::base64_url_encode; + +fn encode_to_string(src: &[u8]) -> std::string::String { + // Unpadded base64 length: ceil(len * 4 / 3). + let encoded_len = src.len().div_ceil(3) * 4 + - match src.len() % 3 { + 1 => 2, + 2 => 1, + _ => 0, + }; + let mut dst = std::vec![0u8; encoded_len]; + + base64_url::encode(&mut dst, src); + + std::string::String::from_utf8(dst).unwrap() +} + +/// RFC 4648 §10 test vectors (unpadded, url-safe alphabet). +#[test] +fn rfc4648_vectors() { + assert_eq!(encode_to_string(b""), ""); + assert_eq!(encode_to_string(b"f"), "Zg"); + assert_eq!(encode_to_string(b"fo"), "Zm8"); + assert_eq!(encode_to_string(b"foo"), "Zm9v"); + assert_eq!(encode_to_string(b"foob"), "Zm9vYg"); + assert_eq!(encode_to_string(b"fooba"), "Zm9vYmE"); + assert_eq!(encode_to_string(b"foobar"), "Zm9vYmFy"); +} + +/// The url-safe alphabet must be used ('-' and '_', not '+' and '/'). +#[test] +fn url_safe_alphabet() { + // 0xfb 0xef 0xbe encodes to "----" in url-safe, "++++" in standard. + assert_eq!(encode_to_string(&[0xfb, 0xef, 0xbe]), "----"); + // 0xff 0xff 0xff encodes to "____" in url-safe, "////" in standard. + assert_eq!(encode_to_string(&[0xff, 0xff, 0xff]), "____"); +} + +/// Cross-check every length 0..=64 against the reference implementation — +/// 32-byte inputs (the challenge case) matter most, but the encoder is +/// length-generic. +#[test] +fn matches_reference_implementation() { + for len in 0usize..=64 { + let src: std::vec::Vec = (0..len).map(|i| (i * 37 + len * 11) as u8).collect(); + assert_eq!( + encode_to_string(&src), + base64_url_encode(&src), + "mismatch at len {len}" + ); + } +} + +/// The exact challenge shape: 32 bytes always encode to 43 chars, unpadded. +#[test] +fn challenge_length() { + let encoded = encode_to_string(&[0xa5; 32]); + assert_eq!(encoded.len(), 43); + assert_eq!(encoded, base64_url_encode(&[0xa5; 32])); +} diff --git a/contracts/smart-wallet/src/tests/test_common.rs b/contracts/smart-wallet/src/tests/test_common.rs new file mode 100644 index 0000000..0c30f42 --- /dev/null +++ b/contracts/smart-wallet/src/tests/test_common.rs @@ -0,0 +1,319 @@ +#![cfg(test)] +//! Shared helpers for the smart wallet test suite. + +extern crate std; + +use ed25519_dalek::{Signer as _, SigningKey}; +use p256::ecdsa::{ + signature::hazmat::PrehashSigner, Signature as P256Signature, SigningKey as P256SigningKey, +}; +use sha2::{Digest, Sha256}; +use smart_wallet_interface::types::{ + Secp256r1Signature, Signature, Signer, SignerExpiration, SignerKey, SignerLimits, SignerStorage, +}; +use soroban_sdk::{ + auth::{Context, ContractContext}, + testutils::EnvTestConfig, + xdr::{ + HashIdPreimage, HashIdPreimageSorobanAuthorization, InvokeContractArgs, Limits, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, ToXdr, VecM, WriteXdr, + }, + Address, Bytes, BytesN, Env, IntoVal, Symbol, Val, Vec, +}; + +use crate::{Contract, ContractClient}; + +pub fn test_env() -> Env { + let mut env = Env::default(); + + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + + env +} + +pub fn register_wallet<'a>(env: &Env, signer: &Signer) -> (Address, ContractClient<'a>) { + let address = env.register(Contract, (signer.clone(),)); + let client = ContractClient::new(env, &address); + + (address, client) +} + +// --- Ed25519 ----------------------------------------------------------- + +pub struct Ed25519Signer { + pub keypair: SigningKey, + pub public_key_bytes: [u8; 32], +} + +impl Ed25519Signer { + /// Deterministic signer from a seed byte. + pub fn new(seed: u8) -> Self { + let keypair = SigningKey::from_bytes(&[seed; 32]); + let public_key_bytes = keypair.verifying_key().to_bytes(); + + Self { + keypair, + public_key_bytes, + } + } + + pub fn public_key(&self, env: &Env) -> BytesN<32> { + BytesN::from_array(env, &self.public_key_bytes) + } + + pub fn signer_key(&self, env: &Env) -> SignerKey { + SignerKey::Ed25519(self.public_key(env)) + } + + pub fn signer( + &self, + env: &Env, + expiration: SignerExpiration, + limits: SignerLimits, + storage: SignerStorage, + ) -> Signer { + Signer::Ed25519(self.public_key(env), expiration, limits, storage) + } + + pub fn sign(&self, env: &Env, payload: &BytesN<32>) -> Signature { + Signature::Ed25519(BytesN::from_array( + env, + &self.keypair.sign(&payload.to_array()).to_bytes(), + )) + } +} + +// --- Secp256r1 / WebAuthn ---------------------------------------------- + +pub struct Passkey { + pub signing_key: P256SigningKey, + pub key_id: [u8; 20], + pub public_key_bytes: [u8; 65], +} + +/// Everything a test might want to corrupt about a WebAuthn assertion. +pub struct WebAuthnOptions { + pub json_type: &'static str, + pub flags: u8, + pub truncate_authenticator_data: bool, + pub challenge_override: Option, + pub json_pad_to: usize, + pub malformed_json: bool, +} + +impl Default for WebAuthnOptions { + fn default() -> Self { + Self { + json_type: "webauthn.get", + // UP (0x01) + UV (0x04): what a real platform authenticator sets. + flags: 0x05, + truncate_authenticator_data: false, + challenge_override: None, + json_pad_to: 0, + malformed_json: false, + } + } +} + +impl Passkey { + /// Deterministic passkey from a seed byte (RFC 6979 signing makes every + /// assertion byte-for-byte reproducible). + pub fn new(seed: u8) -> Self { + let signing_key = P256SigningKey::from_bytes(&[seed; 32].into()).unwrap(); + let public_key_bytes: [u8; 65] = signing_key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + + Self { + signing_key, + key_id: [seed; 20], + public_key_bytes, + } + } + + pub fn key_id(&self, env: &Env) -> Bytes { + Bytes::from_slice(env, &self.key_id) + } + + pub fn public_key(&self, env: &Env) -> BytesN<65> { + BytesN::from_array(env, &self.public_key_bytes) + } + + pub fn signer_key(&self, env: &Env) -> SignerKey { + SignerKey::Secp256r1(self.key_id(env)) + } + + pub fn signer( + &self, + env: &Env, + expiration: SignerExpiration, + limits: SignerLimits, + storage: SignerStorage, + ) -> Signer { + Signer::Secp256r1( + self.key_id(env), + self.public_key(env), + expiration, + limits, + storage, + ) + } + + pub fn sign(&self, env: &Env, payload: &BytesN<32>) -> Signature { + self.sign_with(env, payload, WebAuthnOptions::default()) + } + + /// Build a WebAuthn assertion over `payload`, shaped like real + /// authenticator output, with optional corruptions. + pub fn sign_with( + &self, + env: &Env, + payload: &BytesN<32>, + options: WebAuthnOptions, + ) -> Signature { + let challenge = match &options.challenge_override { + Some(challenge) => challenge.clone(), + None => base64_url_encode(&payload.to_array()), + }; + + let mut client_data_json = if options.malformed_json { + std::string::String::from("this is not json") + } else { + std::format!( + r#"{{"type":"{}","challenge":"{}","origin":"http://localhost:4507","crossOrigin":false}}"#, + options.json_type, + challenge + ) + }; + + // Pad with trailing spaces to force an oversized clientDataJSON + // without changing its JSON meaning. + while client_data_json.len() < options.json_pad_to { + client_data_json.push(' '); + } + + // rpIdHash (32) + flags (1) + signCount (4). + let mut authenticator_data = std::vec::Vec::new(); + authenticator_data.extend_from_slice(&Sha256::digest(b"localhost")); + authenticator_data.push(options.flags); + authenticator_data.extend_from_slice(&[0u8; 4]); + + if options.truncate_authenticator_data { + authenticator_data.truncate(36); + } + + // The signed message per WebAuthn: + // sha256(authenticatorData || sha256(clientDataJSON)). + let mut message = authenticator_data.clone(); + message.extend_from_slice(&Sha256::digest(client_data_json.as_bytes())); + let digest = Sha256::digest(&message); + + let signature: P256Signature = self.signing_key.sign_prehash(&digest).unwrap(); + // The host rejects malleable (high-S) signatures. + let signature = signature.normalize_s().unwrap_or(signature); + + Signature::Secp256r1(Secp256r1Signature { + authenticator_data: Bytes::from_slice(env, &authenticator_data), + client_data_json: Bytes::from_slice(env, client_data_json.as_bytes()), + signature: BytesN::from_array(env, &signature.to_bytes().into()), + }) + } +} + +pub fn base64_url_encode(bytes: &[u8]) -> std::string::String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + URL_SAFE_NO_PAD.encode(bytes) +} + +// --- Contexts and payloads ---------------------------------------------- + +/// An arbitrary fixed payload for direct `__check_auth` invocations. +pub fn payload(env: &Env, seed: u8) -> BytesN<32> { + BytesN::from_array(env, &[seed; 32]) +} + +/// A `transfer`-shaped contract context. +pub fn transfer_context(env: &Env, token: &Address, from: &Address, amount: i128) -> Context { + Context::Contract(ContractContext { + contract: token.clone(), + fn_name: Symbol::new(env, "transfer"), + args: (from.clone(), from.clone(), amount).into_val(env), + }) +} + +/// A context invoking `fn_name` on `contract` with the given args. +pub fn contract_context(env: &Env, contract: &Address, fn_name: &str, args: Vec) -> Context { + Context::Contract(ContractContext { + contract: contract.clone(), + fn_name: Symbol::new(env, fn_name), + args, + }) +} + +/// The wallet-self `remove_signer(key)` context. +pub fn remove_signer_context(env: &Env, wallet: &Address, key: &SignerKey) -> Context { + contract_context( + env, + wallet, + "remove_signer", + soroban_sdk::vec![env, key.into_val(env)], + ) +} + +/// Compute the real Soroban authorization payload hash for a root invocation, +/// mirroring what the host signs for `SorobanCredentials::Address`. +pub fn auth_payload( + env: &Env, + nonce: i64, + signature_expiration_ledger: u32, + invocation: &SorobanAuthorizedInvocation, +) -> BytesN<32> { + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id: env.ledger().network_id().to_array().into(), + nonce, + signature_expiration_ledger, + invocation: invocation.clone(), + }); + let preimage = preimage.to_xdr(Limits::none()).unwrap(); + let preimage = Bytes::from_slice(env, preimage.as_slice()); + + env.crypto().sha256(&preimage).to_bytes() +} + +/// Build a `transfer` `SorobanAuthorizedInvocation` for full-stack tests. +pub fn transfer_invocation( + token: &Address, + from: &Address, + to: &Address, + amount: i128, +) -> SorobanAuthorizedInvocation { + SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: token.clone().try_into().unwrap(), + function_name: "transfer".try_into().unwrap(), + args: std::vec![ + from.clone().try_into().unwrap(), + to.clone().try_into().unwrap(), + amount.try_into().unwrap(), + ] + .try_into() + .unwrap(), + }), + sub_invocations: VecM::default(), + } +} + +/// Extract the last-32-byte ed25519/contract raw key from an Address. +pub fn address_raw_bytes(env: &Env, address: &Address) -> [u8; 32] { + let bytes = address.clone().to_xdr(env); + let bytes = bytes.slice(bytes.len() - 32..); + let mut array = [0u8; 32]; + bytes.copy_into_slice(&mut array); + + array +} diff --git a/contracts/smart-wallet/src/tests/test_deploy.rs b/contracts/smart-wallet/src/tests/test_deploy.rs new file mode 100644 index 0000000..b4260b7 --- /dev/null +++ b/contracts/smart-wallet/src/tests/test_deploy.rs @@ -0,0 +1,89 @@ +#![cfg(test)] +//! The wallet as a deployer: a real `CreateContractV2HostFn` authorization +//! through the host, plus the negative (limited signers cannot deploy — +//! that rule's unit coverage lives in test_auth.rs). + +extern crate std; + +use example_contract::{Contract as ExampleContract, ContractClient as ExampleContractClient}; +use smart_wallet_interface::types::{Signatures, SignerExpiration, SignerLimits, SignerStorage}; +use soroban_sdk::{ + map, + xdr::{ + ContractExecutable, ContractId, ContractIdPreimage, ContractIdPreimageFromAddress, + CreateContractArgsV2, Hash, ScAddress, SorobanAddressCredentials, + SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanAuthorizedInvocation, + SorobanCredentials, Uint256, VecM, + }, + Env, +}; + +use crate::tests::test_common::*; + +mod sample_policy_wasm { + use soroban_sdk::auth::Context; + soroban_sdk::contractimport!(file = "fixtures/sample_policy.wasm"); +} + +/// An unlimited signer authorizes the wallet deploying a contract (the +/// deterministic deploy path the SDK uses, wallet as the deployer address). +#[test] +fn wallet_deploys_contract() { + let env: Env = test_env(); + + let signature_expiration_ledger = env.ledger().sequence(); + let signer = Ed25519Signer::new(31); + + let (wallet_address, _) = register_wallet( + &env, + &signer.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + let example_contract_address = env.register(ExampleContract, ()); + let example_contract_client = ExampleContractClient::new(&env, &example_contract_address); + + let wasm_hash = env + .deployer() + .upload_contract_wasm(sample_policy_wasm::WASM); + + let wallet_address_array = address_raw_bytes(&env, &wallet_address); + + let root_invocation = SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::CreateContractV2HostFn(CreateContractArgsV2 { + contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress { + address: ScAddress::Contract(ContractId(Hash::from(wallet_address_array))), + salt: Uint256(wasm_hash.to_array()), + }), + executable: ContractExecutable::Wasm(Hash::from(wasm_hash.to_array())), + constructor_args: VecM::default(), + }), + sub_invocations: VecM::default(), + }; + + let nonce = 0i64; + let payload = auth_payload(&env, nonce, signature_expiration_ledger, &root_invocation); + + let root_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: wallet_address.clone().try_into().unwrap(), + nonce, + signature_expiration_ledger, + signature: Signatures(map![ + &env, + (signer.signer_key(&env), signer.sign(&env, &payload)), + ]) + .try_into() + .unwrap(), + }), + root_invocation: root_invocation.clone(), + }; + + example_contract_client + .set_auths(&[root_auth]) + .deploy(&wallet_address, &wasm_hash); +} diff --git a/contracts/smart-wallet/src/tests/test_extra.rs b/contracts/smart-wallet/src/tests/test_extra.rs deleted file mode 100644 index cff7a46..0000000 --- a/contracts/smart-wallet/src/tests/test_extra.rs +++ /dev/null @@ -1,131 +0,0 @@ -#![cfg(test)] - -extern crate std; - -use example_contract::{Contract as ExampleContract, ContractClient as ExampleContractClient}; -use smart_wallet_interface::types::{ - Signature, Signatures, Signer, SignerExpiration, SignerKey, SignerLimits, SignerStorage, -}; -use soroban_sdk::{ - map, - testutils::EnvTestConfig, - xdr::{ - ContractExecutable, ContractId, ContractIdPreimage, ContractIdPreimageFromAddress, - CreateContractArgsV2, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, Limits, - ScAddress, SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanAuthorizedFunction, - SorobanAuthorizedInvocation, SorobanCredentials, ToXdr, Uint256, VecM, WriteXdr, - }, - Address, Bytes, BytesN, Env, -}; -use stellar_strkey::{ed25519, Strkey}; - -use crate::Contract; -use ed25519_dalek::{Signer as _, SigningKey}; - -mod sample_policy { - use soroban_sdk::auth::Context; - soroban_sdk::contractimport!(file = "../target/wasm32v1-none/release/sample_policy.wasm"); -} - -#[test] -fn test_deploy_contract() { - let mut env: Env = Env::default(); - - env.set_config(EnvTestConfig { - capture_snapshot_at_drop: false, - }); - - let signature_expiration_ledger = env.ledger().sequence(); - - // Super Ed25519 - let super_ed25519_keypair = SigningKey::from_keypair_bytes(&[ - 88, 206, 67, 128, 240, 45, 168, 148, 191, 111, 180, 111, 104, 83, 214, 113, 78, 27, 55, 86, - 200, 247, 164, 163, 76, 236, 24, 208, 115, 40, 231, 255, 161, 115, 141, 114, 97, 125, 136, - 247, 117, 105, 60, 155, 144, 51, 216, 187, 185, 157, 18, 126, 169, 172, 15, 4, 148, 13, - 208, 144, 53, 12, 91, 78, - ]) - .unwrap(); - - let super_ed25519_strkey = Strkey::PublicKeyEd25519(ed25519::PublicKey( - super_ed25519_keypair.verifying_key().to_bytes(), - )); - let super_ed25519 = Bytes::from_slice(&env, super_ed25519_strkey.to_string().as_bytes()); - let super_ed25519 = Address::from_string_bytes(&super_ed25519); - - let super_ed25519_bytes = super_ed25519.to_xdr(&env); - let super_ed25519_bytes = super_ed25519_bytes.slice(super_ed25519_bytes.len() - 32..); - let mut super_ed25519_array = [0u8; 32]; - super_ed25519_bytes.copy_into_slice(&mut super_ed25519_array); - let super_ed25519_bytes = BytesN::from_array(&env, &super_ed25519_array); - - let super_ed25519_signer_key = SignerKey::Ed25519(super_ed25519_bytes.clone()); - // - - let wallet_address = env.register( - Contract, - (Signer::Ed25519( - super_ed25519_bytes, - SignerExpiration(None), - SignerLimits(None), - SignerStorage::Persistent, - ),), - ); - - let example_contract_address = env.register(ExampleContract, ()); - let example_contract_client = ExampleContractClient::new(&env, &example_contract_address); - - let wasm_hash = env.deployer().upload_contract_wasm(sample_policy::WASM); - - let wallet_address_bytes = wallet_address.clone().to_xdr(&env); - let wallet_address_bytes = wallet_address_bytes.slice(wallet_address_bytes.len() - 32..); - let mut wallet_address_array = [0u8; 32]; - wallet_address_bytes.copy_into_slice(&mut wallet_address_array); - - let root_invocation = SorobanAuthorizedInvocation { - function: SorobanAuthorizedFunction::CreateContractV2HostFn(CreateContractArgsV2 { - contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress { - address: ScAddress::Contract(ContractId(Hash::from(wallet_address_array))), - salt: Uint256(wasm_hash.to_array()), - }), - executable: ContractExecutable::Wasm(Hash::from(wasm_hash.to_array())), - constructor_args: VecM::default(), - }), - sub_invocations: VecM::default(), - }; - - let payload = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { - network_id: env.ledger().network_id().to_array().into(), - nonce: 0, - signature_expiration_ledger, - invocation: root_invocation.clone(), - }); - let payload = payload.to_xdr(Limits::none()).unwrap(); - let payload = Bytes::from_slice(&env, payload.as_slice()); - let payload = env.crypto().sha256(&payload); - - let super_ed25519_signature = Signature::Ed25519(BytesN::from_array( - &env, - &super_ed25519_keypair - .sign(payload.to_array().as_slice()) - .to_bytes(), - )); - - let root_auth = SorobanAuthorizationEntry { - credentials: SorobanCredentials::Address(SorobanAddressCredentials { - address: wallet_address.clone().try_into().unwrap(), - nonce: 0, - signature_expiration_ledger, - signature: Signatures(map![ - &env, - (super_ed25519_signer_key.clone(), super_ed25519_signature), - ]) - .try_into() - .unwrap(), - }), - root_invocation: root_invocation.clone(), - }; - - example_contract_client - .set_auths(&[root_auth]) - .deploy(&wallet_address, &wasm_hash); -} diff --git a/contracts/smart-wallet/src/tests/test_integration.rs b/contracts/smart-wallet/src/tests/test_integration.rs new file mode 100644 index 0000000..2cca137 --- /dev/null +++ b/contracts/smart-wallet/src/tests/test_integration.rs @@ -0,0 +1,226 @@ +#![cfg(test)] +//! Full-stack integration: real `SorobanAuthorizationEntry` credentials +//! through `set_auths`, exercising the wallet as a custom account end-to-end +//! for both signer types, plus a budget regression print. + +use std::println; +extern crate std; + +use example_contract::{Contract as ExampleContract, ContractClient as ExampleContractClient}; +use sample_policy::Contract as PolicyContract; +use smart_wallet_interface::types::{ + Signatures, Signer, SignerExpiration, SignerKey, SignerLimits, SignerStorage, +}; +use soroban_sdk::{ + map, + testutils::Address as _, + token, + xdr::{ + InvokeContractArgs, SorobanAddressCredentials, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, + }, + Address, Env, String, +}; + +use crate::tests::test_common::*; + +fn register_sac(env: &Env) -> (Address, token::StellarAssetClient<'_>, token::Client<'_>) { + let sac_admin = Address::from_string(&String::from_str( + env, + "GD7777777777777777777777777777777777777777777777777773DB", + )); + let sac = env.register_stellar_asset_contract_v2(sac_admin); + let sac_address = sac.address(); + + ( + sac_address.clone(), + token::StellarAssetClient::new(env, &sac_address), + token::Client::new(env, &sac_address), + ) +} + +/// The original kitchen-sink flow: an example contract fans out into two +/// transfers under one wallet authorization, authorized by a limited ed25519 +/// signer whose limits chain into a policy co-signer. +#[test] +fn ed25519_with_policy_limits_full_stack() { + let env = test_env(); + + let signature_expiration_ledger = env.ledger().sequence(); + let amount = 10_000_000i128; + let second_amount = 1_000_000i128; + + let super_signer = Ed25519Signer::new(11); + let simple_signer = Ed25519Signer::new(12); + + let (wallet_address, wallet_client) = register_wallet( + &env, + &super_signer.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + let example_contract_address = env.register(ExampleContract, ()); + let example_contract_client = ExampleContractClient::new(&env, &example_contract_address); + + let (sac_address, sac_admin_client, sac_client) = register_sac(&env); + + sac_admin_client + .mock_all_auths() + .mint(&wallet_address, &100_000_000); + + let sample_policy_address = env.register(PolicyContract, ()); + let sample_policy_signer_key = SignerKey::Policy(sample_policy_address.clone()); + + // simple signer: may invoke the SAC only with the policy's approval, and + // the example contract freely. + wallet_client + .mock_all_auths() + .add_signer(&simple_signer.signer( + &env, + SignerExpiration(None), + SignerLimits(Some(map![ + &env, + ( + sac_address.clone(), + Some(soroban_sdk::vec![&env, sample_policy_signer_key.clone()]) + ), + (example_contract_address.clone(), None), + ])), + SignerStorage::Temporary, + )); + + // The policy must be installed on the wallet for its policy__ to accept. + wallet_client.mock_all_auths().add_signer(&Signer::Policy( + sample_policy_address.clone(), + SignerExpiration(None), + SignerLimits(Some(map![ + &env, + ( + sac_address.clone(), + Some(soroban_sdk::vec![&env, simple_signer.signer_key(&env)]) + ), + ])), + SignerStorage::Temporary, + )); + + let root_invocation = SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: example_contract_address.clone().try_into().unwrap(), + function_name: "call".try_into().unwrap(), + args: std::vec![ + sac_address.clone().try_into().unwrap(), + wallet_address.clone().try_into().unwrap(), + sac_address.clone().try_into().unwrap(), + amount.try_into().unwrap(), + ] + .try_into() + .unwrap(), + }), + sub_invocations: std::vec![ + transfer_invocation(&sac_address, &wallet_address, &sac_address, amount), + transfer_invocation(&sac_address, &wallet_address, &sac_address, second_amount), + ] + .try_into() + .unwrap(), + }; + + let nonce = 3i64; + let payload = auth_payload(&env, nonce, signature_expiration_ledger, &root_invocation); + + let root_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: wallet_address.clone().try_into().unwrap(), + nonce, + signature_expiration_ledger, + signature: Signatures(map![ + &env, + ( + simple_signer.signer_key(&env), + simple_signer.sign(&env, &payload) + ), + ]) + .try_into() + .unwrap(), + }), + root_invocation: root_invocation.clone(), + }; + + env.cost_estimate().budget().reset_default(); + + example_contract_client.set_auths(&[root_auth]).call( + &sac_address, + &wallet_address, + &sac_address, + &amount, + ); + + // Both sub-transfers (10M + 1M) moved wallet -> SAC contract address. + assert_eq!(sac_client.balance(&wallet_address), 89_000_000); + assert_eq!(sac_client.balance(&sac_address), 11_000_000); + + // Budget regression watermark — compare across contract changes. + println!("{:?}", env.cost_estimate().budget().print()); +} + +/// The core product path, previously untested (audit F1): a WebAuthn passkey +/// authorizes a real transfer through address credentials. +#[test] +fn secp256r1_passkey_full_stack() { + let env = test_env(); + + let signature_expiration_ledger = env.ledger().sequence(); + let amount = 5_000_000i128; + + let passkey = Passkey::new(21); + + let (wallet_address, _) = register_wallet( + &env, + &passkey.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + let (sac_address, sac_admin_client, sac_client) = register_sac(&env); + + sac_admin_client + .mock_all_auths() + .mint(&wallet_address, &100_000_000); + + // A contract-address recipient: no classic trustline requirements. + let recipient_address = Address::generate(&env); + + let root_invocation = + transfer_invocation(&sac_address, &wallet_address, &recipient_address, amount); + + let nonce = 1i64; + let payload = auth_payload(&env, nonce, signature_expiration_ledger, &root_invocation); + + let root_auth = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: wallet_address.clone().try_into().unwrap(), + nonce, + signature_expiration_ledger, + signature: Signatures(map![ + &env, + (passkey.signer_key(&env), passkey.sign(&env, &payload)), + ]) + .try_into() + .unwrap(), + }), + root_invocation, + }; + + sac_client + .set_auths(&[root_auth]) + .transfer(&wallet_address, &recipient_address, &amount); + + assert_eq!(sac_client.balance(&wallet_address), 100_000_000 - amount); + assert_eq!(sac_client.balance(&recipient_address), amount); +} diff --git a/contracts/smart-wallet/src/tests/test_webauthn.rs b/contracts/smart-wallet/src/tests/test_webauthn.rs new file mode 100644 index 0000000..2412215 --- /dev/null +++ b/contracts/smart-wallet/src/tests/test_webauthn.rs @@ -0,0 +1,322 @@ +#![cfg(test)] +//! WebAuthn (secp256r1 passkey) verification vectors, driven through +//! `__check_auth` via `try_invoke_contract_check_auth`. +//! +//! Assertions are generated with deterministic keys (RFC 6979) and shaped +//! exactly like real authenticator output: rpIdHash = sha256("localhost"), +//! flags byte, zero sign counter, and a Chrome-style clientDataJSON with +//! type/challenge/origin/crossOrigin fields. + +extern crate std; + +use smart_wallet_interface::types::{ + Error, Signatures, SignerExpiration, SignerKey, SignerLimits, SignerStorage, +}; +use soroban_sdk::{auth::Context, map, testutils::Address as _, Address, Env, IntoVal, Vec}; + +use crate::tests::test_common::*; + +struct Setup { + env: Env, + wallet: Address, + passkey: Passkey, + payload: soroban_sdk::BytesN<32>, + contexts: Vec, +} + +fn setup() -> Setup { + let env = test_env(); + let passkey = Passkey::new(1); + + let (wallet, _) = register_wallet( + &env, + &passkey.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = soroban_sdk::vec![&env, transfer_context(&env, &token, &wallet, 1_000_000)]; + + Setup { + env, + wallet, + passkey, + payload, + contexts, + } +} + +fn check( + s: &Setup, + signature: smart_wallet_interface::types::Signature, +) -> Result<(), Result> { + let signatures = Signatures(map![&s.env, (s.passkey.signer_key(&s.env), signature)]); + + s.env.try_invoke_contract_check_auth::( + &s.wallet, + &s.payload, + signatures.into_val(&s.env), + &s.contexts, + ) +} + +#[test] +fn valid_assertion() { + let s = setup(); + let signature = s.passkey.sign(&s.env, &s.payload); + + assert_eq!(check(&s, signature), Ok(())); +} + +/// Pin the derived challenge for a fixed payload: base64url([0x07; 32]), +/// 43 chars, unpadded. This is the load-bearing payload binding. +#[test] +fn fixed_vector_challenge() { + let s = setup(); + + assert_eq!( + base64_url_encode(&s.payload.to_array()), + "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc" + ); + + let signature = s.passkey.sign(&s.env, &s.payload); + assert_eq!(check(&s, signature), Ok(())); +} + +/// UV is deliberately not required: a UP-only assertion (flags 0x01) is valid. +#[test] +fn user_verification_not_required() { + let s = setup(); + let signature = s.passkey.sign_with( + &s.env, + &s.payload, + WebAuthnOptions { + flags: 0x01, + ..Default::default() + }, + ); + + assert_eq!(check(&s, signature), Ok(())); +} + +/// UP flag missing (UV alone doesn't count). +#[test] +fn user_presence_required() { + let s = setup(); + let signature = s.passkey.sign_with( + &s.env, + &s.payload, + WebAuthnOptions { + flags: 0x04, + ..Default::default() + }, + ); + + assert_eq!(check(&s, signature), Err(Ok(Error::UserPresenceRequired))); +} + +#[test] +fn no_flags_set() { + let s = setup(); + let signature = s.passkey.sign_with( + &s.env, + &s.payload, + WebAuthnOptions { + flags: 0x00, + ..Default::default() + }, + ); + + assert_eq!(check(&s, signature), Err(Ok(Error::UserPresenceRequired))); +} + +/// authenticatorData shorter than the WebAuthn minimum of 37 bytes. +#[test] +fn truncated_authenticator_data() { + let s = setup(); + let signature = s.passkey.sign_with( + &s.env, + &s.payload, + WebAuthnOptions { + truncate_authenticator_data: true, + ..Default::default() + }, + ); + + assert_eq!( + check(&s, signature), + Err(Ok(Error::InvalidAuthenticatorData)) + ); +} + +/// A registration ceremony response replayed as an assertion must fail. +#[test] +fn wrong_webauthn_type() { + let s = setup(); + let signature = s.passkey.sign_with( + &s.env, + &s.payload, + WebAuthnOptions { + json_type: "webauthn.create", + ..Default::default() + }, + ); + + assert_eq!(check(&s, signature), Err(Ok(Error::InvalidWebAuthnType))); +} + +/// A (correctly signed) assertion over the WRONG challenge must fail: this is +/// the binding between the WebAuthn assertion and the Soroban auth payload. +#[test] +fn challenge_mismatch() { + let s = setup(); + let other_payload = payload(&s.env, 8); + let signature = s.passkey.sign_with( + &s.env, + &s.payload, + WebAuthnOptions { + challenge_override: Some(base64_url_encode(&other_payload.to_array())), + ..Default::default() + }, + ); + + assert_eq!( + check(&s, signature), + Err(Ok(Error::ClientDataJsonChallengeIncorrect)) + ); +} + +/// clientDataJSON larger than the 1024-byte parse buffer: typed error, not an +/// untyped panic. +#[test] +fn oversized_client_data_json() { + let s = setup(); + let signature = s.passkey.sign_with( + &s.env, + &s.payload, + WebAuthnOptions { + json_pad_to: 1025, + ..Default::default() + }, + ); + + assert_eq!(check(&s, signature), Err(Ok(Error::ClientDataJsonTooLarge))); +} + +/// Exactly 1024 bytes still parses (boundary). +#[test] +fn max_size_client_data_json() { + let s = setup(); + let signature = s.passkey.sign_with( + &s.env, + &s.payload, + WebAuthnOptions { + json_pad_to: 1024, + ..Default::default() + }, + ); + + assert_eq!(check(&s, signature), Ok(())); +} + +#[test] +fn malformed_client_data_json() { + let s = setup(); + let signature = s.passkey.sign_with( + &s.env, + &s.payload, + WebAuthnOptions { + malformed_json: true, + ..Default::default() + }, + ); + + assert_eq!( + check(&s, signature), + Err(Ok(Error::ClientDataJsonParseError)) + ); +} + +/// Corrupted signature bytes fail secp256r1 verification in the host — not +/// one of the wallet's typed errors. (The cross-payload case is covered by +/// `challenge_mismatch`: the challenge IS the payload binding.) +#[test] +fn invalid_signature() { + let s = setup(); + let signature = match s.passkey.sign(&s.env, &s.payload) { + smart_wallet_interface::types::Signature::Secp256r1(mut sig) => { + let mut bytes = sig.signature.to_array(); + bytes[63] ^= 0xff; + sig.signature = soroban_sdk::BytesN::from_array(&s.env, &bytes); + smart_wallet_interface::types::Signature::Secp256r1(sig) + } + _ => unreachable!(), + }; + + let result = check(&s, signature); + assert!(result.is_err(), "corrupted signature must not verify"); + assert!( + !matches!(result, Err(Ok(_))), + "must fail in host crypto, not a wallet-typed error: {result:?}" + ); +} + +/// An assertion from a DIFFERENT key for this signer's key id fails host +/// crypto verification. +#[test] +fn wrong_key_signature() { + let s = setup(); + let other_passkey = Passkey::new(2); + let signature = other_passkey.sign(&s.env, &s.payload); + + let result = check(&s, signature); + assert!(result.is_err(), "foreign-key signature must not verify"); + assert!(!matches!(result, Err(Ok(_)))); +} + +/// A secp256r1 signature submitted for a stored Ed25519 signer key is a +/// key/value mismatch. +#[test] +fn signature_for_wrong_signer_type() { + let env = test_env(); + let passkey = Passkey::new(1); + let ed25519 = Ed25519Signer::new(3); + + let (wallet, _) = register_wallet( + &env, + &ed25519.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + let payload = payload(&env, 7); + let token = Address::generate(&env); + let contexts = soroban_sdk::vec![&env, transfer_context(&env, &token, &wallet, 1_000_000)]; + + // Ed25519 signer key mapped to a Secp256r1 (WebAuthn) signature. + let signatures = Signatures(map![ + &env, + ( + SignerKey::Ed25519(ed25519.public_key(&env)), + passkey.sign(&env, &payload) + ) + ]); + + assert_eq!( + env.try_invoke_contract_check_auth::( + &wallet, + &payload, + signatures.into_val(&env), + &contexts, + ), + Err(Ok(Error::SignatureKeyValueMismatch)) + ); +} From 26b0082b05cfba39aa58fd1916147e19f34e615a Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 21:44:43 -0400 Subject: [PATCH 09/30] docs(contracts): canonical testnet deployment manifest + hash pin Builds all three contracts from ff7bd22, uploads to testnet (passkey-kit-deployer, friendbot-funded), verifies fetch-back hashes, and live-smokes a deployed wallet instance (constructor + get_signer). Canonical smart-wallet wasm hash: 9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe Manifest records toolchain/build provenance, per-component hashes + upload txs, the NORMATIVE derivation tuple (salt=sha256(keyId), canonical kalepail deployer, F7 connect-verification requirement), and the legacy e45c42b9 hash. Co-Authored-By: Claude Fable 5 --- contracts/Makefile | 4 +- docs/deployments-testnet-2026-07-11.md | 122 +++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 docs/deployments-testnet-2026-07-11.md diff --git a/contracts/Makefile b/contracts/Makefile index 452225c..92f07d5 100644 --- a/contracts/Makefile +++ b/contracts/Makefile @@ -4,8 +4,8 @@ export STELLAR_ACCOUNT ?= default # Native XLM SAC on testnet, used only for sac-sdk bindings generation. SAC_ID ?= CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC -# Canonical wasm hashes live in docs/deployments-testnet.md — this Makefile -# does not pin them; `make build` output is the source of truth. +# Canonical wasm hashes live in ../docs/deployments-testnet-2026-07-11.md — +# builds only reproduce those hashes from the exact commit recorded there. # `stellar contract build` optimizes by default (CLI 27+); out/smart_wallet.wasm # is the canonical, already-optimized artifact. diff --git a/docs/deployments-testnet-2026-07-11.md b/docs/deployments-testnet-2026-07-11.md new file mode 100644 index 0000000..5675561 --- /dev/null +++ b/docs/deployments-testnet-2026-07-11.md @@ -0,0 +1,122 @@ +# passkey-kit v1 contract build + testnet deployment — 2026-07-11 + +This manifest is THE canonical hash source for the passkey-kit v1 contract +set. The Makefile, SDK configuration, `verify:bindings` drift guard, and the +zephyr (Mercury) ingestion allowlist must all consume the hashes recorded +here — never a locally rebuilt hash. + +> **Status: pre-audit-gate.** These artifacts were built after the A1/A2 +> contract rework and test suite, but before the independent 3-way audit +> (Fable + gpt-5.6-sol + terra adversarial review). Any audit-driven contract +> change re-pins this manifest; the table below is superseded by a newer +> `docs/deployments-*.md` if one exists. + +## Build provenance + +- Repo commit: `ff7bd227a30db8c0613a46ccebccc8d81f94ef04` (branch `overhaul/ground-up`) +- Rust/Cargo: `1.94.0` (pinned in `contracts/rust-toolchain.toml`) +- `soroban-sdk`: `27.0.0` (`e5cb4b52c3da8e56fc48adfd7b85d85976c1a059`) +- Stellar CLI: `27.0.0` (`5a7c5fe76530bf4248477ac812fc757146b98cc4`) +- Build target: `wasm32v1-none` +- Build command: `stellar contract build --locked --package --out-dir out` + (optimization is on by default in CLI 27; `out/.wasm` is the final artifact) +- Contract meta: `binver: 1.0.0` + +Note: the wasm hash is sensitive to source line numbers (panic `Location` +data is embedded), so only builds from the exact commit above reproduce these +hashes. Reproducibility was verified by rebuilding from a clean `out/`. + +| Component | Cargo package | Bytes | SHA-256 / network WASM hash | +|---|---|---:|---| +| Smart wallet | `smart-wallet` | 29,183 | `9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe` | +| Sample policy | `sample-policy` | 10,144 | `e6d0038301764191467ff245de4f95645cbd626d36f0598317f734ad73c164f6` | +| Example contract | `example-contract` | 1,047 | `47a3326360bdce2ca360ed1b226ad636e14ac698f63efc11ab28e0d41f1f76aa` | + +The smart wallet WASM is uploaded but never deployed as a singleton: every +user wallet deploys its own instance with a `Signer` constructor argument. +The sample policy demonstrates the v1 policy lifecycle +(`install`/`uninstall`/`policy__`). The example contract is a test fixture +(deploy-path and multi-transfer auth exercises); it is uploaded for +completeness but nothing depends on it. + +## Testnet + +Upload source: `passkey-kit-deployer` +(`GBUOCX45SICUYPFQG2YYQN2VTBDVG5EBS7Q4AFKAMMZIRR77CE6WHRF6`), funded via +friendbot. Network: Test SDF Network ; September 2015. + +| Component | Upload transaction | +|---|---| +| Smart wallet | `f0ef240c4ce44d9271318abe367b3ebea9578966df0370d338b2d557d0efd916` | +| Sample policy | `2a1ebc27b98eef868d7415ce281f930d94ac535a8df4957e6146cf6e06a27fa4` | +| Example contract | `9d4da6b0992de465154ffea9dca3ea3e72948b0f706ec7e5a7f920dc4ddb2de7` | + +### Live smoke + +A wallet instance was deployed from the uploaded WASM with an Ed25519 +constructor signer (tx +`5f4ef8114835f64c3b2e2125034190109ab29c6375435f12ceaedc8cb4808670`): + +- Instance: `CCHSQWC5BDRAPL4JGGKHUNHNY2QD2JZCDV6QNC34HIBACQAYKRB6IX4N` +- `get_signer({"Ed25519": })` returned + `{"Ed25519":[[null],[null]]}` — the constructor-stored signer, unlimited, + no expiration. + +### Verification + +Every uploaded WASM was fetched back by hash +(`stellar contract fetch --wasm-hash --network testnet`) and +re-hashed with `shasum -a 256`. All three fetched hashes matched the local +artifacts byte-for-byte. + +## Mainnet + +Not deployed. Mainnet upload is an orchestrator-gated endgame step and will +be recorded in a follow-up manifest after the audit gate passes. + +## Deterministic wallet address derivation (NORMATIVE) + +Every passkey-kit wallet address is derived from the WebAuthn credential id +(`keyId`) alone — both indexer backends and `connectWallet` reverse lookup +depend on this exact tuple. **None of these inputs may ever change:** + +```text +contractId = sha256(XDR(HashIdPreimage::EnvelopeTypeContractId { + networkId: sha256(network passphrase), + contractIdPreimage: ContractIdPreimageFromAddress { + address: G-address of the canonical deployer keypair, + salt: sha256(keyId), + }, +})) +``` + +- Canonical deployer keypair: `Keypair.fromRawEd25519Seed(sha256(utf8("kalepail")))` + → `GC2C7AWLS2FMFTQAHW3IBUB4ZXVP4E37XNLEF2IK7IVXBB6CMEPCSXFO`. + The SDK exposes a `deploySource` override, but the default MUST remain this + keypair or previously created wallets become unreachable by derivation. +- The WASM hash is deliberately NOT part of the preimage: contract upgrades + do not move wallet addresses, and the same `keyId` derives the same address + across contract versions. +- **Security consequence (audit F7)**: because the deployer is public and the + executable is not bound by the preimage, anyone who learns a `keyId` can + front-run the derived address with arbitrary code. Clients MUST verify + ownership before trusting a derived or reverse-looked-up address: + 1. the `keyId` is an actual stored signer (`get_signer` returns a value), and + 2. (recommended) the instance executable hash is on the known-hash list + from this manifest (or its legacy predecessor below). + +## Legacy (pre-1.0) build + +The pre-overhaul contract (soroban-sdk 23, `sw_v1` tuple events, errors 1-9, +ledger-sequence expirations) has canonical WASM hash: + +```text +e45c42b944a767bd5f37f8c4a469b48917d28e23481dbfd550419c84cdacde92 +``` + +Wallets deployed from it remain live on both networks and derive from the +SAME derivation tuple above (the tuple predates v1 and is unchanged). v1 is +forward-only: no compatibility shims exist, and legacy wallets interact only +with legacy tooling — but a legacy wallet can be upgraded in place to a v1 +hash via its own auth (`update_contract_code` on legacy, `upgrade` from then +on), keeping its address. From 67a662d9104effc10ba2c4086208a3fcfc2fbf78 Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 21:45:30 -0400 Subject: [PATCH 10/30] B3 SDK: relayer ^0.20 wrapper + server-only PasskeyServer split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todo 950. Relayer client + true client/server separation: - relayer.ts: RelayerClient wraps ChannelsClient ^0.20 — submitSorobanTransaction ({func,auth}) preferred + submitTransaction({xdr}) for envelopes + getTransaction polling; skipWait/fundRelayerId passthrough; PluginClientError family mapped to typed RelayerError (or decoded ContractError); typed never-throw TransactionResult. - server.ts: PasskeyServer rewritten server-only — validated config at construction, drops PasskeyBase for plain NetworkContext (rpc + passphrase), RelayerClient integration (smart func/auth vs xdr routing via hasSourceAccountAuth), hardened Mercury methods (AbortController timeout, typed IndexerError instead of raw JSON throws). Superseded by the SignerIndexer abstraction in B5. - Server-only entrypoint: PasskeyServer + RelayerClient removed from the main `passkey-kit` export; reachable ONLY via `passkey-kit/server` so the relayer secret can never be bundled into browser code. verify-esm asserts the split. - sac decision: KEEP the generated sac-sdk client (demo needs full SEP-41 reads: balance, metadata, transfer) rather than collapsing to a helper; SACClient made standalone (base.ts deleted). Added buildTokenTransferHostFunction for the low-level relayer {func,auth} path. (sac-sdk regen from canonical source = B4.) - constants: DEFAULT_RELAYER_TIMEOUT_MS (6min testnet-retry window), CHANNELS_MAINNET/TESTNET_URL, DEFAULT_INDEXER_TIMEOUT_MS; TransactionSuccess gains optional transactionId (skipWait polling). pnpm build (+ verify-esm) and vitest (96 tests) green. Co-Authored-By: Claude Opus 4.8 --- scripts/verify-esm.mjs | 4 +- src/base.ts | 18 -- src/constants.ts | 25 +++ src/index.ts | 7 +- src/relayer.test.ts | 115 ++++++++++ src/relayer.ts | 181 ++++++++++++++++ src/sac.ts | 77 ++++--- src/server.ts | 464 +++++++++++++++++++++++++++-------------- src/types.ts | 4 +- 9 files changed, 686 insertions(+), 209 deletions(-) delete mode 100644 src/base.ts create mode 100644 src/relayer.test.ts create mode 100644 src/relayer.ts diff --git a/scripts/verify-esm.mjs b/scripts/verify-esm.mjs index 911a4fd..3058bb1 100644 --- a/scripts/verify-esm.mjs +++ b/scripts/verify-esm.mjs @@ -34,17 +34,17 @@ async function check(specifier, expectedExports) { console.log("Node ESM import smoke test:"); await check("passkey-kit", [ "PasskeyKit", - "PasskeyServer", "PasskeyKitError", "PasskeyEventEmitter", "deriveContractAddress", + "buildTokenTransferHostFunction", ]); await check("passkey-kit/storage", [ "MemoryStorage", "LocalStorageAdapter", "IndexedDBStorage", ]); -await check("passkey-kit/server", ["PasskeyServer"]); +await check("passkey-kit/server", ["PasskeyServer", "RelayerClient"]); if (failures.length) { console.error("\nESM import smoke test FAILED:"); diff --git a/src/base.ts b/src/base.ts deleted file mode 100644 index c513df1..0000000 --- a/src/base.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Server } from "@stellar/stellar-sdk/rpc" - -// TODO consider adding support for a signAuthEntry method that conforms to the ed25519 signature scheme of this passkey interface -// once we do that we can clean the code a little with the `xdr.HashIdPreimage.envelopeTypeSorobanAuthorization` stuff -// ... note I've re-read the above and I've currently got no clue what this is asking for. Maybe check git-blame for when it was added to try and find some context -// actually this is just talking about adding support for signing transactions with an ed25519 key as well as a passkey. Simple enough - -export class PasskeyBase { - public rpcUrl: string | undefined - public rpc: Server | undefined - - constructor(rpcUrl?: string) { - if (rpcUrl) { - this.rpcUrl = rpcUrl - this.rpc = new Server(rpcUrl) - } - } -} \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts index c38a7dc..8dcbf2e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -95,6 +95,31 @@ export const IDB_INDEX_CONTRACT_ID = "contractId"; /** localStorage key under which passkey credentials are persisted. */ export const LOCALSTORAGE_CREDENTIALS_KEY = "passkey-kit:credentials"; +// ============================================================================ +// Relayer +// ============================================================================ + +/** + * Default relayer request timeout, in milliseconds (6 minutes). + * + * Long enough to absorb testnet channel-account funding retries after a network + * reset. Mainnet requests return quickly; this only bounds the maximum wait. + */ +export const DEFAULT_RELAYER_TIMEOUT_MS = 360_000; + +/** OpenZeppelin managed Channels relayer endpoint (mainnet). */ +export const CHANNELS_MAINNET_URL = "https://channels.openzeppelin.com"; + +/** OpenZeppelin managed Channels relayer endpoint (testnet). */ +export const CHANNELS_TESTNET_URL = "https://channels.openzeppelin.com/testnet"; + +// ============================================================================ +// Indexer +// ============================================================================ + +/** Default indexer request timeout, in milliseconds. */ +export const DEFAULT_INDEXER_TIMEOUT_MS = 10_000; + // ============================================================================ // Networks // ============================================================================ diff --git a/src/index.ts b/src/index.ts index 66fd087..aad2d1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,15 +8,16 @@ * @packageDocumentation */ -// Core clients +// Core client. (The server-only `PasskeyServer` lives at `passkey-kit/server` +// so it — and the relayer secret it holds — can never be bundled into browser +// code.) export { PasskeyKit, type PasskeyKitConfig, type CreateOptions, type ConnectOptions, } from "./kit.js"; -export { PasskeyServer } from "./server.js"; -export { SACClient } from "./sac.js"; +export { SACClient, buildTokenTransferHostFunction } from "./sac.js"; // Generated contract client export { Client as PasskeyClient } from "passkey-kit-sdk"; diff --git a/src/relayer.test.ts b/src/relayer.test.ts new file mode 100644 index 0000000..d4e4ecb --- /dev/null +++ b/src/relayer.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import { PluginExecutionError } from "@openzeppelin/relayer-plugin-channels"; +import { RelayerClient } from "./relayer.js"; +import { ContractError, RelayerError, PasskeyKitErrorCode } from "./errors.js"; + +/** Build a RelayerClient with its private ChannelsClient replaced by a fake. */ +function withChannels(fake: Record): RelayerClient { + const relayer = new RelayerClient({ baseUrl: "https://relayer.test", apiKey: "k" }); + (relayer as unknown as { channels: unknown }).channels = fake; + return relayer; +} + +describe("RelayerClient config", () => { + it("throws when baseUrl or apiKey is missing", () => { + expect(() => new RelayerClient({ baseUrl: "", apiKey: "k" })).toThrow( + RelayerError + ); + expect(() => new RelayerClient({ baseUrl: "u", apiKey: "" })).toThrow( + RelayerError + ); + }); +}); + +describe("RelayerClient.send", () => { + it("returns a success result for a confirmed submission", async () => { + const submitSorobanTransaction = vi.fn(async () => ({ + transactionId: "tx-1", + hash: "abc123", + status: "confirmed", + })); + const relayer = withChannels({ submitSorobanTransaction }); + + const result = await relayer.send("FUNC", ["AUTH"]); + + expect(result).toEqual({ success: true, hash: "abc123", transactionId: "tx-1" }); + expect(submitSorobanTransaction).toHaveBeenCalledWith({ + func: "FUNC", + auth: ["AUTH"], + skipWait: undefined, + fundRelayerId: undefined, + }); + }); + + it("returns a typed failure when the relayer reports a failed status", async () => { + const relayer = withChannels({ + submitSorobanTransaction: vi.fn(async () => ({ + transactionId: "tx-2", + hash: "def", + status: "failed", + })), + }); + + const result = await relayer.send("FUNC", []); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeInstanceOf(RelayerError); + expect(result.hash).toBe("def"); + } + }); + + it("never throws on a PluginClientError — maps it to a RelayerError", async () => { + const relayer = withChannels({ + submitSorobanTransaction: vi.fn(async () => { + throw new PluginExecutionError("insufficient balance", { + code: "FEE_LIMIT_EXCEEDED", + }); + }), + }); + + const result = await relayer.send("FUNC", []); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeInstanceOf(RelayerError); + expect(result.error.code).toBe(PasskeyKitErrorCode.RELAYER_REQUEST_FAILED); + expect(result.error.context).toMatchObject({ category: "execution" }); + } + }); + + it("decodes an on-chain contract error surfaced by the relayer", async () => { + const relayer = withChannels({ + submitSorobanTransaction: vi.fn(async () => { + throw new PluginExecutionError("HostError: Error(Contract, #4)"); + }), + }); + + const result = await relayer.send("FUNC", []); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeInstanceOf(ContractError); + expect((result.error as ContractError).contractErrorName).toBe( + "SignerExpired" + ); + } + }); +}); + +describe("RelayerClient.sendTransaction", () => { + it("submits an envelope via submitTransaction", async () => { + const submitTransaction = vi.fn(async () => ({ + transactionId: "tx-9", + hash: "hh", + status: "success", + })); + const relayer = withChannels({ submitTransaction }); + + const result = await relayer.sendTransaction("ENVELOPE_XDR", { skipWait: true }); + + expect(result.success).toBe(true); + expect(submitTransaction).toHaveBeenCalledWith({ + xdr: "ENVELOPE_XDR", + skipWait: true, + fundRelayerId: undefined, + }); + }); +}); diff --git a/src/relayer.ts b/src/relayer.ts new file mode 100644 index 0000000..e255f92 --- /dev/null +++ b/src/relayer.ts @@ -0,0 +1,181 @@ +/** + * Server-side relayer client. + * + * A thin, typed wrapper over the OpenZeppelin Channels client + * (`@openzeppelin/relayer-plugin-channels` ^0.20) for fee-sponsored submission. + * It holds the relayer API key, so it MUST run server-side only (it is reached + * through `PasskeyServer`, exported from the `passkey-kit/server` subpath). + * + * Two submission modes: + * - {@link RelayerClient.send} — `{ func, auth }` for invokeHostFunction flows + * (the preferred Soroban path; the relayer builds the envelope + pays fees). + * - {@link RelayerClient.sendTransaction} — `{ xdr }` for a signed envelope + * (e.g. a deploy transaction that needs source-account auth + a fee bump). + * + * Every method returns a discriminated {@link TransactionResult} and NEVER + * throws for expected relayer/on-chain failures — a `PluginClientError` is + * mapped to a typed {@link RelayerError} (or a {@link ContractError} when a + * contract code can be decoded from its details). + * + * @packageDocumentation + */ + +import { + ChannelsClient, + PluginClientError, + type ChannelsTransactionResponse, +} from "@openzeppelin/relayer-plugin-channels"; +import type { TransactionResult, TransactionFailure } from "./types.js"; +import { RelayerError, PasskeyKitErrorCode } from "./errors.js"; +import { + decodeContractError, + failedTransaction, +} from "./contract-errors.js"; +import { DEFAULT_RELAYER_TIMEOUT_MS } from "./constants.js"; + +/** Configuration for a {@link RelayerClient}. */ +export interface RelayerClientConfig { + /** Base URL of the Channels relayer service. */ + baseUrl: string; + /** API key for the relayer service (server-side secret). */ + apiKey: string; + /** Optional admin secret for management operations. */ + adminSecret?: string; + /** Request timeout in ms (default {@link DEFAULT_RELAYER_TIMEOUT_MS}). */ + timeout?: number; +} + +/** Per-submission relayer options. */ +export interface RelayerSubmitOptions { + /** Return immediately after submission; poll {@link RelayerClient.getTransaction}. */ + skipWait?: boolean; + /** Alternative fund-relayer id for the fee bump (must be allow-listed). */ + fundRelayerId?: string; +} + +/** A relayer status string that indicates on-chain failure. */ +const FAILURE_STATUS = /fail|error|revert|reject/i; + +export class RelayerClient { + private readonly channels: ChannelsClient; + + constructor(config: RelayerClientConfig) { + if (!config.baseUrl || !config.apiKey) { + throw new RelayerError( + "RelayerClient requires both baseUrl and apiKey", + PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED + ); + } + this.channels = new ChannelsClient({ + baseUrl: config.baseUrl, + apiKey: config.apiKey, + adminSecret: config.adminSecret, + timeout: config.timeout ?? DEFAULT_RELAYER_TIMEOUT_MS, + }); + } + + /** + * Submit an invokeHostFunction via `{ func, auth }` (the preferred Soroban + * path). The relayer builds the transaction envelope with a channel account + * and pays the fees. + */ + async send( + func: string, + auth: string[], + options?: RelayerSubmitOptions + ): Promise { + return this.run(() => + this.channels.submitSorobanTransaction({ + func, + auth, + skipWait: options?.skipWait, + fundRelayerId: options?.fundRelayerId, + }) + ); + } + + /** + * Submit a signed transaction envelope via `{ xdr }` for a fee bump (preserves + * the inner signature; use for deploys / source-account-auth transactions). + */ + async sendTransaction( + xdr: string, + options?: RelayerSubmitOptions + ): Promise { + return this.run(() => + this.channels.submitTransaction({ + xdr, + skipWait: options?.skipWait, + fundRelayerId: options?.fundRelayerId, + }) + ); + } + + /** Poll a previously-submitted (`skipWait`) transaction by its relayer id. */ + async getTransaction(transactionId: string): Promise { + return this.run(() => this.channels.getTransaction({ transactionId })); + } + + private async run( + fn: () => Promise + ): Promise { + try { + return this.toResult(await fn()); + } catch (err) { + return this.mapError(err); + } + } + + private toResult(res: ChannelsTransactionResponse): TransactionResult { + if (res.status && FAILURE_STATUS.test(res.status)) { + return failedTransaction( + new RelayerError( + `Relayer reported status "${res.status}"`, + PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, + { status: res.status, transactionId: res.transactionId } + ), + res.hash ?? undefined + ); + } + + return { + success: true, + hash: res.hash ?? "", + ...(res.transactionId ? { transactionId: res.transactionId } : {}), + }; + } + + private mapError(err: unknown): TransactionFailure { + // Prefer a decoded contract error when the relayer surfaced one. + const details = + err instanceof PluginClientError + ? (err as { errorDetails?: unknown }).errorDetails + : undefined; + const contractError = + (err instanceof Error && decodeContractError(err.message)) || + decodeContractError(details); + if (contractError) { + return failedTransaction(contractError); + } + + if (err instanceof PluginClientError) { + return failedTransaction( + new RelayerError( + err.message, + PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, + { category: err.category, details }, + err + ) + ); + } + + return failedTransaction( + new RelayerError( + err instanceof Error ? err.message : String(err), + PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, + undefined, + err instanceof Error ? err : undefined + ) + ); + } +} diff --git a/src/sac.ts b/src/sac.ts index a6165aa..401dd70 100644 --- a/src/sac.ts +++ b/src/sac.ts @@ -1,29 +1,58 @@ -import { Client as SacClient } from 'sac-sdk' -import { PasskeyBase } from "./base.js" -import type { Server } from '@stellar/stellar-sdk/rpc' +/** + * Stellar Asset Contract (SEP-41) helpers. + * + * DECISION (todo 950): keep the generated `sac-sdk` client rather than collapsing + * it to a bare `buildTokenTransferHostFunction` helper. The demo needs the full + * SEP-41 surface (balance, name/symbol/decimals reads, transfer) for token UX, + * which a transfer-only host-function builder cannot provide. We ALSO expose + * {@link buildTokenTransferHostFunction} for the low-level relayer `{func,auth}` + * path. (`sac-sdk` regeneration from a canonical source is handled in B4.) + * + * @packageDocumentation + */ -export class SACClient extends PasskeyBase { - declare rpc: Server - declare rpcUrl: string +import { Address, xdr, nativeToScVal } from "@stellar/stellar-sdk"; +import { Client as SacClient } from "sac-sdk"; - public networkPassphrase: string - - constructor(options: { - networkPassphrase: string, - rpcUrl: string - }) { - const { networkPassphrase, rpcUrl } = options +/** A thin factory around the generated SEP-41 `sac-sdk` client. */ +export class SACClient { + readonly networkPassphrase: string; + readonly rpcUrl: string; - super(rpcUrl) + constructor(options: { networkPassphrase: string; rpcUrl: string }) { + this.networkPassphrase = options.networkPassphrase; + this.rpcUrl = options.rpcUrl; + } - this.networkPassphrase = networkPassphrase - } + /** Build a SEP-41 client for a specific SAC/token contract. */ + getSACClient(sacContractId: string): SacClient { + return new SacClient({ + contractId: sacContractId, + networkPassphrase: this.networkPassphrase, + rpcUrl: this.rpcUrl, + }); + } +} - public getSACClient(SACContractId: string) { - return new SacClient({ - contractId: SACContractId, - networkPassphrase: this.networkPassphrase, - rpcUrl: this.rpcUrl, - }) - } -} \ No newline at end of file +/** + * Build a raw SEP-41 `transfer(from, to, amount)` host function, for the + * low-level relayer `{ func, auth }` submission path (no client instance needed). + */ +export function buildTokenTransferHostFunction( + tokenContract: string, + from: string, + to: string, + amountInStroops: bigint +): xdr.HostFunction { + return xdr.HostFunction.hostFunctionTypeInvokeContract( + new xdr.InvokeContractArgs({ + contractAddress: Address.fromString(tokenContract).toScAddress(), + functionName: "transfer", + args: [ + xdr.ScVal.scvAddress(Address.fromString(from).toScAddress()), + xdr.ScVal.scvAddress(Address.fromString(to).toScAddress()), + nativeToScVal(amountInStroops, { type: "i128" }), + ], + }) + ); +} diff --git a/src/server.ts b/src/server.ts index 2352002..360dc4e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,179 +1,321 @@ -import { xdr } from "@stellar/stellar-sdk" -import { PasskeyBase } from "./base.js" -import base64url from "./base64url.js" -import type { Tx } from "@stellar/stellar-sdk/contract" -import type { IndexedSigner } from "./types.js" -import { AssembledTransaction } from "@stellar/stellar-sdk/contract" -import { Durability } from "@stellar/stellar-sdk/rpc" -import { ChannelsClient } from "@openzeppelin/relayer-plugin-channels" - -export class PasskeyServer extends PasskeyBase { - private relayerApiKey: string | undefined - private mercuryJwt: string | undefined - private mercuryKey: string | undefined - private channelsClient: ChannelsClient | undefined - - public relayerUrl: string | undefined - public mercuryProjectName: string | undefined - public mercuryUrl: string | undefined - - constructor(options: { - rpcUrl?: string, - relayerUrl?: string, - relayerApiKey?: string, - mercuryProjectName?: string, - mercuryUrl?: string, - mercuryJwt?: string, - mercuryKey?: string, - }) { - const { - rpcUrl, - relayerUrl, - relayerApiKey, - mercuryProjectName, - mercuryUrl, - mercuryJwt, - mercuryKey, - } = options - - super(rpcUrl) - - if (relayerUrl) - this.relayerUrl = relayerUrl - - if (relayerApiKey) - this.relayerApiKey = relayerApiKey - - if (relayerUrl && relayerApiKey) { - this.channelsClient = new ChannelsClient({ - baseUrl: relayerUrl, - apiKey: relayerApiKey, - }) - } +/** + * `PasskeyServer` — the SERVER-ONLY counterpart to {@link PasskeyKit}. + * + * It holds secrets (the relayer API key, indexer JWTs) and therefore must never + * run in a browser bundle: it is exported exclusively from the + * `passkey-kit/server` subpath so a bundler can't pull it into client code, and + * its config must come from server-side environment variables (never + * `VITE_`-prefixed). + * + * Responsibilities: fee-sponsored submission via {@link RelayerClient} and + * signer discovery via an indexer. The indexer methods here are a server-side + * Mercury port; B5 (todo 952) replaces them with the `SignerIndexer` abstraction + * over both Mercury and Stellar Indexer backends. + * + * @packageDocumentation + */ - if (mercuryProjectName) - this.mercuryProjectName = mercuryProjectName +import { + Operation, + TransactionBuilder, + xdr, + type Transaction, +} from "@stellar/stellar-sdk"; +import { Durability, Server } from "@stellar/stellar-sdk/rpc"; +import { AssembledTransaction } from "@stellar/stellar-sdk/contract"; +import base64url from "./base64url.js"; +import { RelayerClient, type RelayerClientConfig, type RelayerSubmitOptions } from "./relayer.js"; +import { + ConfigurationError, + IndexerError, + RelayerError, + PasskeyKitErrorCode, +} from "./errors.js"; +import { failedTransaction } from "./contract-errors.js"; +import { DEFAULT_INDEXER_TIMEOUT_MS } from "./constants.js"; +import type { IndexedSigner, TransactionResult } from "./types.js"; - if (mercuryUrl) - this.mercuryUrl = mercuryUrl +// Re-export the relayer surface so `passkey-kit/server` is the single +// server-only entrypoint (secrets never reach the browser bundle). +export { + RelayerClient, + type RelayerClientConfig, + type RelayerSubmitOptions, +} from "./relayer.js"; - if (mercuryJwt) - this.mercuryJwt = mercuryJwt +/** Mercury indexer (Zephyr) configuration. */ +export interface MercuryConfig { + /** Mercury base URL (e.g. https://api.mercurydata.app). */ + url: string; + /** Deployed Zephyr project name. */ + projectName: string; + /** Bearer JWT (server-side secret). Prefer this over `apiKey`. */ + jwt?: string; + /** Raw API key (server-side secret). */ + apiKey?: string; +} - if (mercuryKey) - this.mercuryKey = mercuryKey +/** Configuration for a {@link PasskeyServer}. */ +export interface PasskeyServerConfig { + /** Network passphrase (required). */ + networkPassphrase: string; + /** Stellar RPC URL (needed for temporary-signer eviction probes). */ + rpcUrl?: string; + /** Relayer configuration for fee-sponsored submission. */ + relayer?: RelayerClientConfig; + /** Mercury indexer configuration. */ + mercury?: MercuryConfig; +} + +/** Normalize any submittable input to a built {@link Transaction}. */ +function toBuiltTransaction( + input: AssembledTransaction | Transaction | string, + networkPassphrase: string +): Transaction { + if (typeof input === "string") { + return TransactionBuilder.fromXDR(input, networkPassphrase) as Transaction; + } + if (input instanceof AssembledTransaction) { + if (!input.built) { + throw new RelayerError( + "AssembledTransaction has not been built/simulated yet", + PasskeyKitErrorCode.RELAYER_REQUEST_FAILED + ); } + return input.built; + } + return input; +} - public async getSigners(contractId: string) { - if (!this.rpc || !this.mercuryProjectName || !this.mercuryUrl || (!this.mercuryJwt && !this.mercuryKey)) - throw new Error('Mercury service not configured') - - const signers = await fetch(`${this.mercuryUrl}/zephyr/execute`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: this.mercuryJwt ? `Bearer ${this.mercuryJwt}` : this.mercuryKey! - }, - body: JSON.stringify({ - project_name: this.mercuryProjectName, - mode: { - Function: { - fname: "get_signers_by_address", - arguments: JSON.stringify({ - address: contractId - }) - } - } - }) - }) - .then(async (res) => { - if (res.ok) - return res.json() - - throw await res.json() - }) - - for (const signer of signers) { - if (signer.storage === 'Temporary') { - try { - await this.rpc.getContractData(contractId, xdr.ScVal.scvBytes(base64url.toBuffer(signer.key)), Durability.Temporary) - } catch { - signer.evicted = true - } - } - } +/** Whether any invokeHostFunction op carries source-account auth. */ +function hasSourceAccountAuth(transaction: Transaction): boolean { + for (const op of transaction.operations) { + if (op.type !== "invokeHostFunction") continue; + for (const entry of (op as Operation.InvokeHostFunction).auth ?? []) { + if (entry.credentials().switch().name === "sorobanCredentialsSourceAccount") { + return true; + } + } + } + return false; +} + +export class PasskeyServer { + readonly networkPassphrase: string; + readonly rpc?: Server; - return signers as IndexedSigner[] + private readonly relayer?: RelayerClient; + private readonly mercury?: MercuryConfig; + + constructor(config: PasskeyServerConfig) { + if (!config.networkPassphrase) { + throw new ConfigurationError( + "networkPassphrase is required", + PasskeyKitErrorCode.MISSING_CONFIG + ); + } + this.networkPassphrase = config.networkPassphrase; + + if (config.rpcUrl) { + this.rpc = new Server(config.rpcUrl); + } + if (config.relayer) { + this.relayer = new RelayerClient(config.relayer); } + if (config.mercury) { + if (!config.mercury.jwt && !config.mercury.apiKey) { + throw new ConfigurationError( + "Mercury config requires a jwt or apiKey", + PasskeyKitErrorCode.INVALID_CONFIG + ); + } + this.mercury = config.mercury; + } + } + + // -- Submission -------------------------------------------------------------- - public async getContractId(options: { - keyId?: string, - publicKey?: string, - policy?: string, - }, index = 0) { - if (!this.mercuryProjectName || !this.mercuryUrl || (!this.mercuryJwt && !this.mercuryKey)) - throw new Error('Mercury service not configured') - - let { keyId, publicKey, policy } = options || {} - - if ([keyId, publicKey, policy].filter((arg) => !!arg).length > 1) - throw new Error('Exactly one of `options.keyId`, `options.publicKey`, or `options.policy` must be provided.'); - - let args: { key: string, kind: 'Secp256r1' | 'Ed25519' | 'Policy' } - - if (keyId) - args = { key: keyId, kind: 'Secp256r1' } - else if (publicKey) - args = { key: publicKey, kind: 'Ed25519' } - else if (policy) - args = { key: policy, kind: 'Policy' } - - const res = await fetch(`${this.mercuryUrl}/zephyr/execute`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: this.mercuryJwt ? `Bearer ${this.mercuryJwt}` : this.mercuryKey! - }, - body: JSON.stringify({ - project_name: this.mercuryProjectName, - mode: { - Function: { - fname: "get_addresses_by_signer", - arguments: JSON.stringify(args!) - } - } - }) - }) - .then(async (res) => { - if (res.ok) - return await res.json() as string[] - - throw await res.json() - }) - - return res[index] + /** + * Submit a transaction via the relayer for fee sponsorship. + * + * invokeHostFunction transactions without source-account auth use the preferred + * `{ func, auth }` Soroban path; everything else (deploys, source-account auth) + * is fee-bumped via the `{ xdr }` envelope path. Never throws — returns a typed + * {@link TransactionResult}. + */ + async send( + input: AssembledTransaction | Transaction | string, + options?: RelayerSubmitOptions + ): Promise { + if (!this.relayer) { + return failedTransaction( + new RelayerError( + "Relayer is not configured on this PasskeyServer", + PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED + ) + ); } - /* LATER - - Add a method for getting a paginated or filtered list of all a wallet's events - */ + let built: Transaction; + try { + built = toBuiltTransaction(input, this.networkPassphrase); + } catch (err) { + return failedTransaction( + err instanceof RelayerError + ? err + : new RelayerError( + err instanceof Error ? err.message : String(err), + PasskeyKitErrorCode.RELAYER_REQUEST_FAILED + ) + ); + } - public async send(txn: AssembledTransaction | Tx | string) { - if (!this.channelsClient) - throw new Error('Relayer service not configured') + const op = built.operations[0]; + if (op?.type === "invokeHostFunction" && !hasSourceAccountAuth(built)) { + const invokeOp = op as Operation.InvokeHostFunction; + const func = invokeOp.func.toXDR("base64"); + const auth = (invokeOp.auth ?? []).map((entry) => entry.toXDR("base64")); + return this.relayer.send(func, auth, options); + } - let txnXdr: string + return this.relayer.sendTransaction(built.toXDR(), options); + } - if (txn instanceof AssembledTransaction) { - txnXdr = txn.built!.toXDR() - } else if (typeof txn !== 'string') { - txnXdr = txn.toXDR() - } else { - txnXdr = txn + /** Poll a `skipWait` submission by its relayer transaction id. */ + getTransaction(transactionId: string): Promise { + if (!this.relayer) { + return Promise.resolve( + failedTransaction( + new RelayerError( + "Relayer is not configured on this PasskeyServer", + PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED + ) + ) + ); + } + return this.relayer.getTransaction(transactionId); + } + + // -- Indexer (server-side Mercury; superseded by SignerIndexer in B5) -------- + + private async mercuryExecute(fname: string, args: unknown): Promise { + if (!this.mercury) { + throw new IndexerError( + "Mercury indexer is not configured", + PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED + ); + } + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + DEFAULT_INDEXER_TIMEOUT_MS + ); + + try { + const response = await fetch(`${this.mercury.url}/zephyr/execute`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: this.mercury.jwt + ? `Bearer ${this.mercury.jwt}` + : this.mercury.apiKey!, + }, + body: JSON.stringify({ + project_name: this.mercury.projectName, + mode: { Function: { fname, arguments: JSON.stringify(args) } }, + }), + signal: controller.signal, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new IndexerError( + `Mercury request failed (${response.status}): ${body.slice(0, 200)}`, + PasskeyKitErrorCode.INDEXER_REQUEST_FAILED, + { fname, status: response.status } + ); + } + + return (await response.json()) as T; + } catch (err) { + if (err instanceof IndexerError) throw err; + if (err instanceof Error && err.name === "AbortError") { + throw new IndexerError( + "Mercury request timed out", + PasskeyKitErrorCode.INDEXER_REQUEST_FAILED, + { fname } + ); + } + throw new IndexerError( + err instanceof Error ? err.message : String(err), + PasskeyKitErrorCode.INDEXER_REQUEST_FAILED, + { fname }, + err instanceof Error ? err : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + /** + * Enumerate a wallet's signers via Mercury, flagging temporary signers whose + * ledger entry has been evicted (Mercury can't observe eviction directly). + */ + async getSigners(contractId: string): Promise { + const signers = await this.mercuryExecute( + "get_signers_by_address", + { address: contractId } + ); + + if (this.rpc) { + for (const signer of signers) { + if (signer.storage === "Temporary") { + try { + await this.rpc.getContractData( + contractId, + xdr.ScVal.scvBytes(base64url.toBuffer(signer.key)), + Durability.Temporary + ); + } catch { + signer.evicted = true; + } } + } + } - return this.channelsClient.submitTransaction({ - xdr: txnXdr, - }) + return signers; + } + + /** + * Reverse lookup: find the wallet(s) a signer belongs to via Mercury. + * + * @returns the address at `index`, or `undefined` if there are none. + */ + async getContractId( + options: { keyId?: string; publicKey?: string; policy?: string }, + index = 0 + ): Promise { + const provided = [options.keyId, options.publicKey, options.policy].filter( + Boolean + ); + if (provided.length !== 1) { + throw new IndexerError( + "Provide exactly one of keyId, publicKey, or policy", + PasskeyKitErrorCode.INDEXER_REQUEST_FAILED + ); } + + let args: { key: string; kind: "Secp256r1" | "Ed25519" | "Policy" }; + if (options.keyId) args = { key: options.keyId, kind: "Secp256r1" }; + else if (options.publicKey) args = { key: options.publicKey, kind: "Ed25519" }; + else args = { key: options.policy!, kind: "Policy" }; + + const addresses = await this.mercuryExecute( + "get_addresses_by_signer", + args + ); + + return addresses[index]; + } } diff --git a/src/types.ts b/src/types.ts index 974387e..b2005ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -187,10 +187,12 @@ export interface ConnectWalletResult { /** Successful result of a submission operation. */ export interface TransactionSuccess { success: true; - /** Transaction hash. */ + /** Transaction hash (may be empty for a `skipWait` submission not yet polled). */ hash: string; /** Ledger the transaction was included in, when known. */ ledger?: number; + /** Relayer transaction id, for polling a `skipWait` submission. */ + transactionId?: string; } /** From 4bd5e6d0e847ba29eda677e7712949f8a0ae4be6 Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 21:54:55 -0400 Subject: [PATCH 11/30] B5 SDK: indexer abstraction + Mercury + Stellar Indexer backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todo 952. One SignerIndexer interface, two interchangeable backends (src/indexer/**): - types.ts: SignerIndexer { getSigners, findWallets, health }; WalletSigner { key, publicKey?, expiration?, limits, storage, status: live|expired|evicted| removed }; IndexerHealth; FindWalletsHardeningDeps. - codec.ts: shared SignerKey/SignerVal codec — signerKeyToContractScVal, signerKeyToIndexerJson (predicate), scValToSignerKey, decodeSignerVal (via bindings Spec, memoized), deriveStatus (inclusive timestamp expiration). - mercury.ts: MercuryIndexer — get_signers_by_address / get_addresses_by_signer via /zephyr/execute; temporary-eviction RPC probe (#598 F6); findWallets derive-then-confirm hardening (#598 F3) reusing kit/wallet-ops.getSigner. - stellar-indexer.ts: StellarIndexerBackend over POST /v1/contract-data/ — contract-scoped enumeration + client-side SignerVal decode; tombstone grouping (removed = tombstoned with no live counterpart in either durability); findWallets = derive candidate (Secp256r1) then confirm (<=25/query). - index.ts: indexerForConfig (null-tolerant seam) + lookupWithRetry (20x2s poll). - Exports: browser-safe types + lookupWithRetry from `passkey-kit`; token-holding backends from `passkey-kit/server`. WIRE NOTE (flagged in-code): exact Mercury row/arg shapes (C1) and Stellar Indexer request/response encoding (hex vs base64 bytes, deleted_at) are confirmed live in F2. Unit tests use mocked fixtures; cross-backend equality is an F2 e2e. pnpm build (+ verify-esm) and vitest (111 tests) green. Co-Authored-By: Claude Opus 4.8 --- src/index.ts | 11 ++ src/indexer/codec.test.ts | 101 ++++++++++++ src/indexer/codec.ts | 192 +++++++++++++++++++++ src/indexer/index.ts | 87 ++++++++++ src/indexer/mercury.test.ts | 104 ++++++++++++ src/indexer/mercury.ts | 247 ++++++++++++++++++++++++++++ src/indexer/stellar-indexer.test.ts | 85 ++++++++++ src/indexer/stellar-indexer.ts | 212 ++++++++++++++++++++++++ src/indexer/types.ts | 80 +++++++++ src/server.ts | 9 + 10 files changed, 1128 insertions(+) create mode 100644 src/indexer/codec.test.ts create mode 100644 src/indexer/codec.ts create mode 100644 src/indexer/index.ts create mode 100644 src/indexer/mercury.test.ts create mode 100644 src/indexer/mercury.ts create mode 100644 src/indexer/stellar-indexer.test.ts create mode 100644 src/indexer/stellar-indexer.ts create mode 100644 src/indexer/types.ts diff --git a/src/index.ts b/src/index.ts index aad2d1b..189a507 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,6 +78,17 @@ export { type ContractErrorInfo, } from "./contract-errors.js"; +// Indexer abstraction: browser-safe types + the pure retry helper. The concrete +// token-holding backends are exported from `passkey-kit/server`. +export { + lookupWithRetry, + type SignerIndexer, + type WalletSigner, + type IndexerHealth, + type SignerStatus, + type SignerStorageClass, +} from "./indexer/index.js"; + // Events export { PasskeyEventEmitter, diff --git a/src/indexer/codec.test.ts b/src/indexer/codec.test.ts new file mode 100644 index 0000000..b56272c --- /dev/null +++ b/src/indexer/codec.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { Keypair, StrKey, xdr } from "@stellar/stellar-sdk"; +import base64url from "../base64url.js"; +import { SignerKey } from "../types.js"; +import { SIGNER_VAL_UDT } from "../kit/auth-payload.js"; +import { + decodeSignerVal, + deriveStatus, + scValToSignerKey, + signerKeyToContractScVal, + signerKeyToIndexerJson, + walletSpec, +} from "./codec.js"; + +/** Encode a native SignerVal to its ScVal via the contract spec. */ +function encodeSignerVal(native: unknown): xdr.ScVal { + return walletSpec().nativeToScVal(native, SIGNER_VAL_UDT); +} + +describe("decodeSignerVal", () => { + it("decodes a Secp256r1 signer value (publicKey, expiration, unlimited)", () => { + const publicKey = Buffer.alloc(65, 4); + const scVal = encodeSignerVal({ + tag: "Secp256r1", + values: [publicKey, [1000], [undefined]], + }); + + const decoded = decodeSignerVal(scVal); + expect(decoded.kind).toBe("Secp256r1"); + expect(Buffer.from(decoded.publicKey!)).toEqual(publicKey); + expect(decoded.expiration).toBe(1000); + expect(decoded.limits).toBeUndefined(); + }); + + it("decodes an Ed25519 signer value with no expiration", () => { + const scVal = encodeSignerVal({ + tag: "Ed25519", + values: [[undefined], [undefined]], + }); + const decoded = decodeSignerVal(scVal); + expect(decoded.kind).toBe("Ed25519"); + expect(decoded.publicKey).toBeUndefined(); + expect(decoded.expiration).toBeUndefined(); + }); +}); + +describe("scValToSignerKey", () => { + it("round-trips a Secp256r1 key", () => { + const keyId = base64url.encode(Buffer.alloc(16, 0xab)); + const scVal = signerKeyToContractScVal(SignerKey.Secp256r1(keyId)); + const decoded = scValToSignerKey(scVal); + expect(decoded.key).toBe("Secp256r1"); + expect(decoded.value).toBe(keyId); + }); + + it("round-trips an Ed25519 key (raw bytes <-> G-address)", () => { + const g = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)).publicKey(); + const scVal = signerKeyToContractScVal(SignerKey.Ed25519(g)); + const decoded = scValToSignerKey(scVal); + expect(decoded.key).toBe("Ed25519"); + expect(StrKey.isValidEd25519PublicKey(decoded.value)).toBe(true); + expect(decoded.value).toBe(g); + }); + + it("round-trips a Policy key", () => { + const c = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL"; + const decoded = scValToSignerKey( + signerKeyToContractScVal(SignerKey.Policy(c)) + ); + expect(decoded.key).toBe("Policy"); + expect(decoded.value).toBe(c); + }); +}); + +describe("signerKeyToIndexerJson", () => { + it("encodes a Secp256r1 key as a symbol+bytes(hex) vector", () => { + const keyId = base64url.encode(Buffer.from([0xde, 0xad])); + expect(signerKeyToIndexerJson(SignerKey.Secp256r1(keyId))).toEqual({ + vec: [{ symbol: "Secp256r1" }, { bytes: "dead" }], + }); + }); + + it("encodes a Policy key as a symbol+address vector", () => { + const c = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL"; + expect(signerKeyToIndexerJson(SignerKey.Policy(c))).toEqual({ + vec: [{ symbol: "Policy" }, { address: c }], + }); + }); +}); + +describe("deriveStatus", () => { + it("derives live / expired / evicted / removed", () => { + expect(deriveStatus({ nowSeconds: 100 })).toBe("live"); + expect(deriveStatus({ expiration: 100, nowSeconds: 100 })).toBe("live"); // inclusive + expect(deriveStatus({ expiration: 99, nowSeconds: 100 })).toBe("expired"); + expect(deriveStatus({ evicted: true, nowSeconds: 100 })).toBe("evicted"); + expect(deriveStatus({ tombstoned: true, evicted: true, nowSeconds: 100 })).toBe( + "removed" + ); + }); +}); diff --git a/src/indexer/codec.ts b/src/indexer/codec.ts new file mode 100644 index 0000000..1cef47e --- /dev/null +++ b/src/indexer/codec.ts @@ -0,0 +1,192 @@ +/** + * Shared SignerKey/SignerVal codec used by both indexer backends. + * + * Centralizes the encoding both backends need: the contract `SignerKey` ScVal + * (for a Stellar Indexer key predicate), the JSON predicate shape, decoding a + * stored `SignerVal` into {@link WalletSigner} parts, and the status derivation. + * + * WIRE NOTE: the Stellar Indexer key/val predicate JSON shape (and whether bytes + * are hex- or base64-encoded) is confirmed live against the beta API in F2. The + * documented `{ vec: [{ symbol }, { bytes|address }] }` shape (with hex bytes) + * is implemented here; adjust in one place if F2 shows otherwise. + * + * @packageDocumentation + */ + +import { Address, StrKey, xdr } from "@stellar/stellar-sdk"; +import type { Spec as ContractSpec } from "@stellar/stellar-sdk/contract"; +import { Client as PasskeyClient, type SignerVal } from "passkey-kit-sdk"; +import { + SignerKey, + type SignerKeyTag, + type SignerLimits, +} from "../types.js"; +import { signerKeyToScVal, SIGNER_VAL_UDT } from "../kit/auth-payload.js"; +import { toContractSignerKey } from "../kit/wallet-ops.js"; +import type { SignerStatus, SignerStorageClass, WalletSigner } from "./types.js"; + +// The contract Spec is network-independent, so memoize a single instance built +// from a throwaway client (constructing a client does not touch the network). +let cachedSpec: ContractSpec | undefined; +export function walletSpec(): ContractSpec { + if (!cachedSpec) { + cachedSpec = ( + new PasskeyClient({ + contractId: + "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL", + networkPassphrase: "Test SDF Network ; September 2015", + rpcUrl: "https://rpc.invalid", + }) as unknown as { spec: ContractSpec } + ).spec; + } + return cachedSpec; +} + +/** Encode an SDK-side {@link SignerKey} into its contract `SignerKey` ScVal. */ +export function signerKeyToContractScVal(key: SignerKey): xdr.ScVal { + return signerKeyToScVal(walletSpec(), toContractSignerKey(key)); +} + +/** + * Build the JSON key predicate for the Stellar Indexer contract-data query. + * + * @see WIRE NOTE at the top of this module. + */ +export function signerKeyToIndexerJson(key: SignerKey): unknown { + const contractKey = toContractSignerKey(key); + if (contractKey.tag === "Policy") { + return { + vec: [{ symbol: "Policy" }, { address: contractKey.values[0] }], + }; + } + const bytes = Buffer.from(contractKey.values[0]).toString("hex"); + return { vec: [{ symbol: contractKey.tag }, { bytes }] }; +} + +/** + * Decode a contract `SignerKey` ScVal (`scvVec([symbol, bytes|address])`) back + * into an SDK-side {@link SignerKey}. Used by the Stellar Indexer backend, which + * returns the stored ledger-entry key. + */ +export function scValToSignerKey(scVal: xdr.ScVal): SignerKey { + const vec = scVal.vec(); + if (!vec || vec.length < 2 || vec[0]!.switch().name !== "scvSymbol") { + throw new Error("SignerKey ScVal is not a tagged vector"); + } + const tag = vec[0]!.sym().toString() as SignerKeyTag; + switch (tag) { + case "Secp256r1": + return SignerKey.Secp256r1( + Buffer.from(vec[1]!.bytes()).toString("base64url") + ); + case "Ed25519": + return SignerKey.Ed25519( + StrKey.encodeEd25519PublicKey(Buffer.from(vec[1]!.bytes())) + ); + case "Policy": + return SignerKey.Policy( + Address.fromScAddress(vec[1]!.address()).toString() + ); + default: + throw new Error(`Unknown SignerKey tag: ${tag}`); + } +} + +/** Decode a stored `SignerVal` ScVal into WalletSigner value parts. */ +export function decodeSignerVal(valScVal: xdr.ScVal): { + kind: SignerKeyTag; + publicKey?: Uint8Array; + expiration?: number; + limits?: SignerLimits; +} { + const native = walletSpec().scValToNative(valScVal, SIGNER_VAL_UDT) as SignerVal; + + // SignerExpiration = [Option]; SignerLimits = [Option]. + const expiration = expirationOf(native); + const limits = limitsOf(native); + + if (native.tag === "Secp256r1") { + return { + kind: "Secp256r1", + publicKey: new Uint8Array(native.values[0]), + expiration, + limits, + }; + } + return { kind: native.tag, expiration, limits }; +} + +function expirationOf(native: SignerVal): number | undefined { + const exp = + native.tag === "Secp256r1" ? native.values[1] : native.values[0]; + const value = exp?.[0]; + return value == null ? undefined : Number(value); +} + +function limitsOf(native: SignerVal): SignerLimits { + const rawLimits = + native.tag === "Secp256r1" ? native.values[2] : native.values[1]; + const map = rawLimits?.[0]; + if (!map) return undefined; + + const out: SignerLimits = new Map(); + for (const [contract, keys] of map.entries()) { + out.set( + contract, + keys + ? keys.map((k) => { + switch (k.tag) { + case "Policy": + return SignerKey.Policy(k.values[0]); + case "Ed25519": + return SignerKey.Ed25519( + Buffer.from(k.values[0]).toString("hex") + ); + case "Secp256r1": + return SignerKey.Secp256r1( + Buffer.from(k.values[0]).toString("base64url") + ); + } + }) + : undefined + ); + } + return out; +} + +/** + * Derive a signer's lifecycle status. + * + * @param nowSeconds - Current UNIX time in seconds (compared against expiration) + */ +export function deriveStatus(args: { + expiration?: number; + evicted?: boolean; + tombstoned?: boolean; + nowSeconds: number; +}): SignerStatus { + if (args.tombstoned) return "removed"; + if (args.evicted) return "evicted"; + // Expiration is inclusive: valid while now <= expiration (contract semantics). + if (args.expiration != null && args.nowSeconds > args.expiration) { + return "expired"; + } + return "live"; +} + +/** Assemble a {@link WalletSigner} from a key, decoded value, storage, status. */ +export function buildWalletSigner( + key: SignerKey, + decoded: ReturnType, + storage: SignerStorageClass, + status: SignerStatus +): WalletSigner { + return { + key, + ...(decoded.publicKey ? { publicKey: decoded.publicKey } : {}), + ...(decoded.expiration != null ? { expiration: decoded.expiration } : {}), + limits: decoded.limits, + storage, + status, + }; +} diff --git a/src/indexer/index.ts b/src/indexer/index.ts new file mode 100644 index 0000000..fde6770 --- /dev/null +++ b/src/indexer/index.ts @@ -0,0 +1,87 @@ +/** + * Indexer abstraction barrel. + * + * The {@link SignerIndexer} interface + {@link WalletSigner} types and the pure + * {@link lookupWithRetry} helper are browser-safe. The concrete backends + * ({@link MercuryIndexer}, {@link StellarIndexerBackend}) take server-side + * tokens, so they are re-exported from the `passkey-kit/server` entrypoint. + * + * @packageDocumentation + */ + +export type { + SignerIndexer, + WalletSigner, + IndexerHealth, + SignerStatus, + SignerStorageClass, + FindWalletsHardeningDeps, +} from "./types.js"; + +export { + MercuryIndexer, + type MercuryIndexerConfig, + type MercurySignerRow, +} from "./mercury.js"; +export { + StellarIndexerBackend, + type StellarIndexerConfig, + type StellarIndexerEntry, + MAX_CONTRACTS_PER_QUERY, +} from "./stellar-indexer.js"; + +export { + signerKeyToContractScVal, + signerKeyToIndexerJson, + scValToSignerKey, + decodeSignerVal, + deriveStatus, +} from "./codec.js"; + +import type { SignerIndexer } from "./types.js"; +import { MercuryIndexer, type MercuryIndexerConfig } from "./mercury.js"; +import { + StellarIndexerBackend, + type StellarIndexerConfig, +} from "./stellar-indexer.js"; + +/** + * Null-tolerant factory: returns the configured backend, or `null` when no + * indexer is configured (callers treat `null` as "discovery disabled"). + */ +export function indexerForConfig(config?: { + mercury?: MercuryIndexerConfig; + stellarIndexer?: StellarIndexerConfig; +}): SignerIndexer | null { + if (config?.mercury) return new MercuryIndexer(config.mercury); + if (config?.stellarIndexer) return new StellarIndexerBackend(config.stellarIndexer); + return null; +} + +/** + * Poll a lookup until it satisfies `predicate` (defaults to "non-empty"), for + * post-write discovery assertions where the indexer lags the ledger. Defaults + * to 20 attempts × 2s. + */ +export async function lookupWithRetry( + fn: () => Promise, + options?: { + attempts?: number; + delayMs?: number; + predicate?: (result: T[]) => boolean; + } +): Promise { + const attempts = options?.attempts ?? 20; + const delayMs = options?.delayMs ?? 2000; + const predicate = options?.predicate ?? ((r) => r.length > 0); + + let last: T[] = []; + for (let attempt = 0; attempt < attempts; attempt += 1) { + last = await fn(); + if (predicate(last)) return last; + if (attempt < attempts - 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + return last; +} diff --git a/src/indexer/mercury.test.ts b/src/indexer/mercury.test.ts new file mode 100644 index 0000000..760b6b1 --- /dev/null +++ b/src/indexer/mercury.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import base64url from "../base64url.js"; +import { SignerKey } from "../types.js"; +import { SIGNER_VAL_UDT } from "../kit/auth-payload.js"; +import { deriveContractAddress } from "../utils.js"; +import { MercuryIndexer, type MercurySignerRow } from "./mercury.js"; +import { walletSpec } from "./codec.js"; + +const TESTNET = "Test SDF Network ; September 2015"; +const DEPLOYER = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 9)).publicKey(); +const OTHER_WALLET = "CBQU3NIOXC3IDGERJWV3YVMSQSIOU2S6NSMH35OS3GPG6XARZFAAT2NL"; + +function secp256r1ValXdr(expiration?: number): string { + return walletSpec() + .nativeToScVal( + { + tag: "Secp256r1", + values: [Buffer.alloc(65, 4), [expiration], [undefined]], + }, + SIGNER_VAL_UDT + ) + .toXDR("base64"); +} + +/** Stub global fetch, routing by the Zephyr `fname` in the request body. */ +function stubFetch(byFname: Record) { + const fetchMock = vi.fn(async (_url: string, init: { body: string }) => { + const body = JSON.parse(init.body); + const fname = body.mode.Function.fname as string; + return { + ok: true, + json: async () => byFname[fname], + text: async () => "", + } as unknown as Response; + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("MercuryIndexer.getSigners", () => { + it("maps rows to WalletSigners (live Secp256r1)", async () => { + const keyId = base64url.encode(Buffer.alloc(16, 1)); + const rows: MercurySignerRow[] = [ + { kind: "Secp256r1", key: keyId, val: secp256r1ValXdr(4_000_000_000), storage: "Persistent" }, + ]; + stubFetch({ get_signers_by_address: rows }); + + const indexer = new MercuryIndexer({ + url: "https://mercury.test", + projectName: "proj", + jwt: "jwt", + now: () => 1000, + }); + const signers = await indexer.getSigners("CWALLET"); + + expect(signers).toHaveLength(1); + expect(signers[0]!.key.key).toBe("Secp256r1"); + expect(signers[0]!.key.value).toBe(keyId); + expect(signers[0]!.storage).toBe("persistent"); + expect(signers[0]!.status).toBe("live"); + expect(signers[0]!.publicKey).toHaveLength(65); + }); +}); + +describe("MercuryIndexer.findWallets hardening", () => { + it("keeps only the deterministically-derived candidate when no rpc is available", async () => { + const keyId = base64url.encode(Buffer.alloc(16, 7)); + const derived = deriveContractAddress( + base64url.toBuffer(keyId), + DEPLOYER, + TESTNET + ); + stubFetch({ get_addresses_by_signer: [derived, OTHER_WALLET] }); + + const indexer = new MercuryIndexer({ + url: "https://mercury.test", + projectName: "proj", + jwt: "jwt", + hardening: { networkPassphrase: TESTNET, deployerPublicKey: DEPLOYER }, + }); + + const wallets = await indexer.findWallets(SignerKey.Secp256r1(keyId)); + // OTHER_WALLET is dropped: not the derived address and no rpc to confirm it. + expect(wallets).toEqual([derived]); + }); + + it("returns candidates unfiltered when neither rpc nor hardening is configured", async () => { + stubFetch({ get_addresses_by_signer: [OTHER_WALLET] }); + const indexer = new MercuryIndexer({ + url: "https://mercury.test", + projectName: "proj", + jwt: "jwt", + }); + const wallets = await indexer.findWallets( + SignerKey.Secp256r1(base64url.encode(Buffer.alloc(16, 8))) + ); + expect(wallets).toEqual([OTHER_WALLET]); + }); +}); diff --git a/src/indexer/mercury.ts b/src/indexer/mercury.ts new file mode 100644 index 0000000..dff58e2 --- /dev/null +++ b/src/indexer/mercury.ts @@ -0,0 +1,247 @@ +/** + * Mercury (Zephyr) indexer backend. + * + * Calls the deployed Zephyr program's serverless functions + * (`get_signers_by_address`, `get_addresses_by_signer`) via + * `POST {url}/zephyr/execute`. + * + * C1 ALIGNMENT: the exact row/argument shapes are finalized by the reworked + * Zephyr program (todo C1). This maps the documented shapes and is reconciled in + * F2's live cross-backend check. Expiration is treated as a UNIX timestamp + * (seconds), per the reworked contract (#602). + * + * @packageDocumentation + */ + +import { xdr } from "@stellar/stellar-sdk"; +import { Durability, type Server } from "@stellar/stellar-sdk/rpc"; +import { SignerKey } from "../types.js"; +import { IndexerError, PasskeyKitErrorCode } from "../errors.js"; +import { DEFAULT_INDEXER_TIMEOUT_MS } from "../constants.js"; +import { getSigner } from "../kit/wallet-ops.js"; +import base64url from "../base64url.js"; +import type { + FindWalletsHardeningDeps, + IndexerHealth, + SignerIndexer, + SignerStorageClass, + WalletSigner, +} from "./types.js"; +import { + buildWalletSigner, + decodeSignerVal, + deriveStatus, + walletSpec, +} from "./codec.js"; +import { deriveContractAddress } from "../utils.js"; + +/** A raw signer row returned by the Zephyr program (documented shape). */ +export interface MercurySignerRow { + kind: "Secp256r1" | "Ed25519" | "Policy"; + /** Signer key: base64url keyId (Secp256r1), hex pubkey (Ed25519), or address. */ + key: string; + /** Base64 XDR of the stored SignerVal. */ + val: string; + /** Storage durability. */ + storage: "Persistent" | "Temporary"; + evicted?: boolean; +} + +export interface MercuryIndexerConfig { + url: string; + projectName: string; + jwt?: string; + apiKey?: string; + /** RPC server — enables the temporary-signer eviction probe + findWallets hardening. */ + rpc?: Server; + /** Deps that let findWallets confirm candidates by deterministic derivation. */ + hardening?: FindWalletsHardeningDeps; + /** Clock source (seconds); injectable for tests. Defaults to `Date.now()`. */ + now?: () => number; +} + +export class MercuryIndexer implements SignerIndexer { + constructor(private readonly config: MercuryIndexerConfig) { + if (!config.url || !config.projectName) { + throw new IndexerError( + "MercuryIndexer requires url and projectName", + PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED + ); + } + } + + private nowSeconds(): number { + return this.config.now ? this.config.now() : Math.floor(Date.now() / 1000); + } + + private async execute(fname: string, args: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + DEFAULT_INDEXER_TIMEOUT_MS + ); + try { + const response = await fetch(`${this.config.url}/zephyr/execute`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: this.config.jwt + ? `Bearer ${this.config.jwt}` + : this.config.apiKey ?? "", + }, + body: JSON.stringify({ + project_name: this.config.projectName, + mode: { Function: { fname, arguments: JSON.stringify(args) } }, + }), + signal: controller.signal, + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new IndexerError( + `Mercury ${fname} failed (${response.status}): ${body.slice(0, 200)}`, + PasskeyKitErrorCode.INDEXER_REQUEST_FAILED, + { fname, status: response.status } + ); + } + return (await response.json()) as T; + } catch (err) { + if (err instanceof IndexerError) throw err; + const aborted = err instanceof Error && err.name === "AbortError"; + throw new IndexerError( + aborted ? `Mercury ${fname} timed out` : String(err), + PasskeyKitErrorCode.INDEXER_REQUEST_FAILED, + { fname }, + err instanceof Error ? err : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + private rowToWalletSigner(row: MercurySignerRow): WalletSigner { + const key = signerKeyFromRow(row); + const decoded = decodeSignerVal( + xdr.ScVal.fromXDR(Buffer.from(row.val, "base64")) + ); + const storage: SignerStorageClass = + row.storage === "Temporary" ? "temporary" : "persistent"; + const status = deriveStatus({ + expiration: decoded.expiration, + evicted: row.evicted, + nowSeconds: this.nowSeconds(), + }); + return buildWalletSigner(key, decoded, storage, status); + } + + async getSigners(wallet: string): Promise { + const rows = await this.execute( + "get_signers_by_address", + { address: wallet } + ); + + // Eviction probe: Mercury cannot observe temporary-entry eviction, so + // confirm temporary signers still exist on-chain (per #598 F6). + if (this.config.rpc) { + for (const row of rows) { + if (row.storage === "Temporary" && !row.evicted) { + row.evicted = !(await this.entryExists(wallet, row)); + } + } + } + + return rows.map((row) => this.rowToWalletSigner(row)); + } + + private async entryExists( + wallet: string, + row: MercurySignerRow + ): Promise { + try { + await this.config.rpc!.getContractData( + wallet, + xdr.ScVal.scvBytes(base64url.toBuffer(row.key)), + Durability.Temporary + ); + return true; + } catch { + return false; + } + } + + async findWallets(key: SignerKey): Promise { + const args = signerToReverseArgs(key); + const candidates = await this.execute( + "get_addresses_by_signer", + args + ); + return this.confirmCandidates(candidates, key); + } + + /** + * Harden the reverse lookup (#598 F3): keep a candidate only if it is either + * the deterministic derivation of the keyId OR still holds the signer entry + * on-chain — never trust an unverified indexer row. + */ + private async confirmCandidates( + candidates: string[], + key: SignerKey + ): Promise { + const { rpc, hardening } = this.config; + if (!rpc && !hardening) return candidates; // nothing to verify with + + const derived = + hardening && key.key === "Secp256r1" + ? deriveContractAddress( + base64url.toBuffer(key.value), + hardening.deployerPublicKey, + hardening.networkPassphrase + ) + : undefined; + + const confirmed: string[] = []; + for (const candidate of candidates) { + if (candidate === derived) { + confirmed.push(candidate); + continue; + } + if (rpc) { + const signer = await getSigner({ rpc, spec: walletSpec() }, candidate, key); + if (signer) confirmed.push(candidate); + } + } + return confirmed; + } + + async health(): Promise { + try { + await this.execute("get_signers_by_address", { + address: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + }); + return { ok: true, backend: "mercury" }; + } catch (err) { + return { + ok: false, + backend: "mercury", + detail: err instanceof Error ? err.message : String(err), + }; + } + } +} + +function signerKeyFromRow(row: MercurySignerRow): SignerKey { + switch (row.kind) { + case "Secp256r1": + return SignerKey.Secp256r1(row.key); + case "Ed25519": + return SignerKey.Ed25519(row.key); + case "Policy": + return SignerKey.Policy(row.key); + } +} + +function signerToReverseArgs(key: SignerKey): { + key: string; + kind: "Secp256r1" | "Ed25519" | "Policy"; +} { + return { key: key.value, kind: key.key }; +} diff --git a/src/indexer/stellar-indexer.test.ts b/src/indexer/stellar-indexer.test.ts new file mode 100644 index 0000000..cf195a2 --- /dev/null +++ b/src/indexer/stellar-indexer.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { xdr } from "@stellar/stellar-sdk"; +import base64url from "../base64url.js"; +import { SignerKey } from "../types.js"; +import { SIGNER_VAL_UDT } from "../kit/auth-payload.js"; +import { + StellarIndexerBackend, + type StellarIndexerEntry, +} from "./stellar-indexer.js"; +import { signerKeyToContractScVal, walletSpec } from "./codec.js"; + +function keyXdr(keyId: string): string { + return signerKeyToContractScVal(SignerKey.Secp256r1(keyId)).toXDR("base64"); +} + +function valXdr(expiration?: number): string { + return walletSpec() + .nativeToScVal( + { + tag: "Secp256r1", + values: [Buffer.alloc(65, 4), [expiration], [undefined]], + }, + SIGNER_VAL_UDT + ) + .toXDR("base64"); +} + +function backend(): StellarIndexerBackend { + return new StellarIndexerBackend({ + url: "https://indexer.test", + accessToken: "token", + now: () => 1000, + }); +} + +describe("StellarIndexerBackend.entriesToSigners", () => { + it("prefers a live entry over a tombstoned twin (durability flip)", () => { + const keyId = base64url.encode(Buffer.alloc(16, 1)); + const key = keyXdr(keyId); + const entries: StellarIndexerEntry[] = [ + { key, val: valXdr(), durability: "temporary", deleted_at: "2026-07-11" }, + { key, val: valXdr(), durability: "persistent", deleted_at: null }, + ]; + + const signers = backend().entriesToSigners(entries); + expect(signers).toHaveLength(1); + expect(signers[0]!.status).toBe("live"); + expect(signers[0]!.storage).toBe("persistent"); + expect(signers[0]!.key.value).toBe(keyId); + }); + + it("marks a key removed when every entry is tombstoned", () => { + const keyId = base64url.encode(Buffer.alloc(16, 2)); + const key = keyXdr(keyId); + const entries: StellarIndexerEntry[] = [ + { key, val: valXdr(), durability: "persistent", deleted_at: "2026-07-11" }, + ]; + const signers = backend().entriesToSigners(entries); + expect(signers[0]!.status).toBe("removed"); + }); + + it("marks a live-but-expired entry expired", () => { + const keyId = base64url.encode(Buffer.alloc(16, 3)); + const entries: StellarIndexerEntry[] = [ + { + key: keyXdr(keyId), + val: valXdr(500), // expiration 500 < now 1000 + durability: "persistent", + deleted_at: null, + }, + ]; + expect(backend().entriesToSigners(entries)[0]!.status).toBe("expired"); + }); +}); + +describe("StellarIndexerBackend.findWallets", () => { + it("returns [] for non-Secp256r1 keys (not derivable here)", async () => { + const result = await backend().findWallets( + SignerKey.Ed25519( + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + ) + ); + expect(result).toEqual([]); + }); +}); diff --git a/src/indexer/stellar-indexer.ts b/src/indexer/stellar-indexer.ts new file mode 100644 index 0000000..27919c0 --- /dev/null +++ b/src/indexer/stellar-indexer.ts @@ -0,0 +1,212 @@ +/** + * Stellar Indexer backend (Creit Tech), over `POST /v1/contract-data/`. + * + * The wallet stores one top-level contract-data entry per signer + * (`key = SignerKey ScVal`, `val = SignerVal`), so signer enumeration is a + * contract-scoped query and reverse lookup is derive-then-confirm: derive the + * wallet address from the keyId, then confirm the signer entry exists there. + * Removed signers appear as tombstoned (`deleted_at`) entries. + * + * WIRE NOTE: the exact request/response field names, key/val encoding (base64 + * XDR vs JSON ScVal), and the `deleted_at` shape are confirmed live against the + * beta API in F2. This codes to the documented shape; adjust the small + * `parseEntry`/`buildBody` seams if F2 shows otherwise. + * + * @packageDocumentation + */ + +import { xdr } from "@stellar/stellar-sdk"; +import { SignerKey } from "../types.js"; +import { IndexerError, PasskeyKitErrorCode } from "../errors.js"; +import { DEFAULT_INDEXER_TIMEOUT_MS } from "../constants.js"; +import { deriveContractAddress } from "../utils.js"; +import type { + FindWalletsHardeningDeps, + IndexerHealth, + SignerIndexer, + SignerStorageClass, + WalletSigner, +} from "./types.js"; +import { + buildWalletSigner, + decodeSignerVal, + deriveStatus, + scValToSignerKey, + signerKeyToIndexerJson, +} from "./codec.js"; + +/** Max contract ids per contract-data request (API cap). */ +export const MAX_CONTRACTS_PER_QUERY = 25; + +/** A raw contract-data entry (documented shape; F2-confirmed). */ +export interface StellarIndexerEntry { + key: string; // base64 XDR of the SignerKey ScVal + val: string; // base64 XDR of the SignerVal ScVal + durability: "persistent" | "temporary"; + deleted_at: string | null; +} + +export interface StellarIndexerConfig { + /** Base URL, e.g. https://api.stellarindexer.com */ + url: string; + /** Bearer access token (server-side secret). */ + accessToken: string; + /** Deps enabling derive-then-confirm reverse lookup. */ + hardening?: FindWalletsHardeningDeps; + /** Clock source (seconds); injectable for tests. */ + now?: () => number; +} + +export class StellarIndexerBackend implements SignerIndexer { + constructor(private readonly config: StellarIndexerConfig) { + if (!config.url || !config.accessToken) { + throw new IndexerError( + "StellarIndexerBackend requires url and accessToken", + PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED + ); + } + } + + private nowSeconds(): number { + return this.config.now ? this.config.now() : Math.floor(Date.now() / 1000); + } + + private async query(body: { + contracts: string[]; + key?: unknown; + }): Promise { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + DEFAULT_INDEXER_TIMEOUT_MS + ); + try { + const response = await fetch(`${this.config.url}/v1/contract-data/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.config.accessToken}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new IndexerError( + `Stellar Indexer request failed (${response.status}): ${text.slice(0, 200)}`, + PasskeyKitErrorCode.INDEXER_REQUEST_FAILED, + { status: response.status } + ); + } + const json = (await response.json()) as unknown; + return extractEntries(json); + } catch (err) { + if (err instanceof IndexerError) throw err; + const aborted = err instanceof Error && err.name === "AbortError"; + throw new IndexerError( + aborted ? "Stellar Indexer request timed out" : String(err), + PasskeyKitErrorCode.INDEXER_REQUEST_FAILED, + undefined, + err instanceof Error ? err : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + async getSigners(wallet: string): Promise { + const entries = await this.query({ contracts: [wallet] }); + return this.entriesToSigners(entries); + } + + /** + * Group entries by signer key and resolve status. A key with any live entry + * is live/expired; a key whose every entry is tombstoned (no live counterpart + * in either durability) is `removed`. + */ + entriesToSigners(entries: StellarIndexerEntry[]): WalletSigner[] { + const groups = new Map(); + for (const entry of entries) { + const list = groups.get(entry.key) ?? []; + list.push(entry); + groups.set(entry.key, list); + } + + const signers: WalletSigner[] = []; + for (const [, group] of groups) { + const live = group.find((e) => e.deleted_at == null); + const chosen = live ?? group[0]!; + const key = scValToSignerKey( + xdr.ScVal.fromXDR(Buffer.from(chosen.key, "base64")) + ); + const decoded = decodeSignerVal( + xdr.ScVal.fromXDR(Buffer.from(chosen.val, "base64")) + ); + const storage: SignerStorageClass = chosen.durability; + const status = live + ? deriveStatus({ + expiration: decoded.expiration, + nowSeconds: this.nowSeconds(), + }) + : "removed"; + signers.push(buildWalletSigner(key, decoded, storage, status)); + } + return signers; + } + + /** + * Reverse lookup via derive-then-confirm. Only Secp256r1 keyIds are + * derivable; Ed25519/Policy signers are not discoverable through this backend + * (use Mercury's event-driven index for those). + */ + async findWallets(key: SignerKey): Promise { + if (key.key !== "Secp256r1") { + return []; + } + if (!this.config.hardening) { + throw new IndexerError( + "findWallets requires hardening deps (networkPassphrase + deployerPublicKey) to derive candidates", + PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED + ); + } + + const candidate = deriveContractAddress( + Buffer.from(key.value, "base64url"), + this.config.hardening.deployerPublicKey, + this.config.hardening.networkPassphrase + ); + + const entries = await this.query({ + contracts: [candidate], + key: signerKeyToIndexerJson(key), + }); + + // Confirmed only if the derived wallet holds a live entry for this key. + const live = entries.some((e) => e.deleted_at == null); + return live ? [candidate] : []; + } + + async health(): Promise { + try { + await this.query({ + contracts: ["CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"], + }); + return { ok: true, backend: "stellar-indexer" }; + } catch (err) { + return { + ok: false, + backend: "stellar-indexer", + detail: err instanceof Error ? err.message : String(err), + }; + } + } +} + +/** Tolerantly pull the entries array out of a few likely response shapes. */ +function extractEntries(json: unknown): StellarIndexerEntry[] { + if (Array.isArray(json)) return json as StellarIndexerEntry[]; + const obj = json as { entries?: unknown; data?: unknown } | null; + if (obj && Array.isArray(obj.entries)) return obj.entries as StellarIndexerEntry[]; + if (obj && Array.isArray(obj.data)) return obj.data as StellarIndexerEntry[]; + return []; +} diff --git a/src/indexer/types.ts b/src/indexer/types.ts new file mode 100644 index 0000000..25a3c21 --- /dev/null +++ b/src/indexer/types.ts @@ -0,0 +1,80 @@ +/** + * Indexer abstraction: one interface, two interchangeable backends (Mercury and + * Stellar Indexer). Both resolve the same {@link WalletSigner} shape so a signer + * added through the demo is discoverable through either backend and asserted + * equal in the F2 cross-backend e2e. + * + * @packageDocumentation + */ + +import type { SignerKey, SignerLimits } from "../types.js"; + +/** Storage durability of a signer's ledger entry. */ +export type SignerStorageClass = "persistent" | "temporary"; + +/** + * Lifecycle status of a signer, derived from the entry + expiration: + * - `live` — present and not expired. + * - `expired` — present but past its expiration timestamp. + * - `evicted` — a temporary entry whose ledger state was evicted (TTL). + * - `removed` — tombstoned (remove_signer), no live counterpart in either + * durability. + */ +export type SignerStatus = "live" | "expired" | "evicted" | "removed"; + +/** A signer as resolved by an indexer backend. */ +export interface WalletSigner { + /** The signer's key (kind + value). */ + key: SignerKey; + /** 65-byte secp256r1 public key, for Secp256r1 signers. */ + publicKey?: Uint8Array; + /** + * Expiration as a UNIX timestamp in seconds (inclusive), if set. The reworked + * contract stores expiration as a timestamp, not a ledger sequence (#602). + */ + expiration?: number; + /** Per-contract limits (`undefined` = unlimited). */ + limits?: SignerLimits; + /** Storage durability of the entry. */ + storage: SignerStorageClass; + /** Derived lifecycle status. */ + status: SignerStatus; +} + +/** Health of an indexer backend. */ +export interface IndexerHealth { + /** Whether the backend answered a health probe. */ + ok: boolean; + /** Backend identifier (e.g. "mercury", "stellar-indexer"). */ + backend: string; + /** Optional human-readable detail. */ + detail?: string; +} + +/** + * A pluggable signer indexer. + * + * Null-tolerant seam (per SAK): a backend that is *not configured* is + * represented as `null` at the call site (see `forNetwork`); a backend that is + * configured but *fails* throws; a health/404 degrades to `{ ok: false }` + * rather than throwing. + */ +export interface SignerIndexer { + /** Enumerate all signers currently indexed for a wallet. */ + getSigners(wallet: string): Promise; + /** Reverse lookup: the wallet contract ids a signer key belongs to. */ + findWallets(key: SignerKey): Promise; + /** Probe backend health (degrades to `{ ok: false }` rather than throwing). */ + health(): Promise; +} + +/** + * Deps for the SDK-side `findWallets` hardening (#598 F3): before trusting a + * reverse-lookup result, the candidate wallet is confirmed by re-deriving its + * address from the keyId (deterministic derivation) — no unverified `res[0]`. + */ +export interface FindWalletsHardeningDeps { + networkPassphrase: string; + /** The canonical deployer `G…` public key used for derivation. */ + deployerPublicKey: string; +} diff --git a/src/server.ts b/src/server.ts index 360dc4e..09a2fdd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -43,6 +43,15 @@ export { type RelayerSubmitOptions, } from "./relayer.js"; +// Token-holding indexer backends (server-side only). +export { + MercuryIndexer, + StellarIndexerBackend, + indexerForConfig, + type MercuryIndexerConfig, + type StellarIndexerConfig, +} from "./indexer/index.js"; + /** Mercury indexer (Zephyr) configuration. */ export interface MercuryConfig { /** Mercury base URL (e.g. https://api.mercurydata.app). */ From aa2bfbd02ed3e1bceafdaa11e27f2de55e68672e Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 22:05:19 -0400 Subject: [PATCH 12/30] B4 SDK: regen passkey-kit-sdk bindings + verify:bindings drift guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todo 951 (ungated). Regenerated from the canonical v1 smart-wallet WASM (9e7fad44…d6fbe, docs/deployments-testnet-2026-07-11.md; Stellar CLI 27.0.0): - packages/passkey-kit-sdk/src/index.ts regenerated (post-gen: copy generated spec + README, preserve B1 packaging). New surface: error codes 100-129 (renumbered, disjoint from legacy 1-9); SignerExpiration = Option (UNIX-timestamp, was ledger-seq u32); upgrade (renamed from update_contract_code); get_signer view (-> Option); add/update/ remove/upgrade now return Result; #[contractevent] structs in the spec (Upgraded/SignerAdded/SignerRemoved/SignerUpdated). - SDK integration: kit/wallet-ops WalletTx = AssembledTransaction>; toSignerExpiration encodes Option (bigint); new upgrade builder threaded through SignerManager + PasskeyKit facade; getSigner ledger-read unchanged (get_signer view now exists but the RPC read is equivalent + wallet-auth-free). - contract-errors: registry resynced to v1 codes 100-125 (family SmartWallet) + kept legacy 1-9 (family SmartWalletLegacy) so errors from pre-1.0 deployed wallets still decode. bindings-sync test asserts the registry matches the regenerated Errors map. - scripts/bindings/{build.sh,verify.sh}: build regenerates from the canonical hash into the package; verify (drift guard) fetches by hash, regenerates to mktemp (trap EXIT cleanup), and diffs the ContractSpec base64 array vs the committed bindings. Wired verify:bindings into prepublishOnly (network gate); verify:esm stays in build (offline). - sac-sdk: KEPT as-is (B3 decision) — standard network-independent SEP-41 token interface, packaging already fixed in B1; no regen/drift-guard warranted. pnpm build (+ verify-esm) green; verify:bindings green (committed bindings match canonical WASM); vitest 112 tests green. Co-Authored-By: Claude Opus 4.8 --- package.json | 4 +- packages/passkey-kit-sdk/README.md | 12 +- packages/passkey-kit-sdk/src/index.ts | 319 +++++++++++++++++--------- scripts/bindings/build.sh | 45 ++++ scripts/bindings/verify.sh | 52 +++++ src/contract-errors.test.ts | 46 ++-- src/contract-errors.ts | 44 ++-- src/kit.ts | 20 +- src/kit/wallet-ops.ts | 44 +++- src/managers/signer-manager.test.ts | 2 +- src/managers/signer-manager.ts | 21 +- 11 files changed, 435 insertions(+), 174 deletions(-) create mode 100755 scripts/bindings/build.sh create mode 100755 scripts/bindings/verify.sh diff --git a/package.json b/package.json index 6a0f561..3d5ebe8 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "sync-version": "node scripts/sync-version.js", "prebuild": "node scripts/sync-version.js", "build:bindings": "pnpm --filter sac-sdk run build && pnpm --filter passkey-kit-sdk run build", + "bindings:regen": "bash scripts/bindings/build.sh", + "verify:bindings": "bash scripts/bindings/verify.sh", "build": "pnpm run build:bindings && tsc && node scripts/verify-esm.mjs", "verify:esm": "node scripts/verify-esm.mjs", "test": "vitest", @@ -39,7 +41,7 @@ "build:demo": "cd demo && pnpm --ignore-workspace install && pnpm run build", "deploy:demo": "npx wrangler pages deploy", "deploy:demo:prod": "npx wrangler pages deploy --env production", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "pnpm run verify:bindings && pnpm run build" }, "dependencies": { "@openzeppelin/relayer-plugin-channels": "^0.20.0", diff --git a/packages/passkey-kit-sdk/README.md b/packages/passkey-kit-sdk/README.md index 1529c74..40d8a97 100644 --- a/packages/passkey-kit-sdk/README.md +++ b/packages/passkey-kit-sdk/README.md @@ -1,6 +1,6 @@ -# passkey-kit-sdk JS +# pks-gen JS -JS library for interacting with [Soroban](https://soroban.stellar.org/) smart contract `passkey-kit-sdk` via Soroban RPC. +JS library for interacting with [Soroban](https://soroban.stellar.org/) smart contract `pks-gen` via Soroban RPC. This library was automatically generated by Soroban CLI using a command similar to: @@ -9,7 +9,7 @@ soroban contract bindings ts \ --rpc-url INSERT_RPC_URL_HERE \ --network-passphrase "INSERT_NETWORK_PASSPHRASE_HERE" \ --contract-id INSERT_CONTRACT_ID_HERE \ - --output-dir ./path/to/passkey-kit-sdk + --output-dir ./path/to/pks-gen ``` The network passphrase and contract ID are exported from [index.ts](./src/index.ts) in the `networks` constant. If you are the one who generated this library and you know that this contract is also deployed to other networks, feel free to update `networks` with other valid options. This will help your contract consumers use this library more easily. @@ -22,7 +22,7 @@ But you don't need to publish this library to NPM to use it. You can add it to y ```json "dependencies": { - "passkey-kit-sdk": "./path/to/this/folder" + "pks-gen": "./path/to/this/folder" } ``` @@ -30,7 +30,7 @@ However, we've actually encountered [frustration](https://github.com/stellar/sor ```json "scripts": { - "postinstall": "soroban contract bindings ts --rpc-url INSERT_RPC_URL_HERE --network-passphrase \"INSERT_NETWORK_PASSPHRASE_HERE\" --id INSERT_CONTRACT_ID_HERE --name passkey-kit-sdk" + "postinstall": "soroban contract bindings ts --rpc-url INSERT_RPC_URL_HERE --network-passphrase \"INSERT_NETWORK_PASSPHRASE_HERE\" --id INSERT_CONTRACT_ID_HERE --name pks-gen" } ``` @@ -41,7 +41,7 @@ Obviously you need to adjust the above command based on the actual command you u Now that you have your library up-to-date and added to your project, you can import it in a file and see inline documentation for all of its exported methods: ```js -import { Contract, networks } from "passkey-kit-sdk" +import { Contract, networks } from "pks-gen" const contract = new Contract({ ...networks.futurenet, // for example; check which networks this library exports diff --git a/packages/passkey-kit-sdk/src/index.ts b/packages/passkey-kit-sdk/src/index.ts index 8fd4167..6587daa 100644 --- a/packages/passkey-kit-sdk/src/index.ts +++ b/packages/passkey-kit-sdk/src/index.ts @@ -1,132 +1,224 @@ import { Buffer } from "buffer"; +import { Address } from "@stellar/stellar-sdk"; import { AssembledTransaction, Client as ContractClient, ClientOptions as ContractClientOptions, MethodOptions, + Result, Spec as ContractSpec, -} from '@stellar/stellar-sdk/contract'; +} from "@stellar/stellar-sdk/contract"; import type { u32, + i32, + u64, + i64, + u128, + i128, + u256, + i256, Option, -} from '@stellar/stellar-sdk/contract'; + Timepoint, + Duration, +} from "@stellar/stellar-sdk/contract"; +export * from "@stellar/stellar-sdk"; +export * as contract from "@stellar/stellar-sdk/contract"; +export * as rpc from "@stellar/stellar-sdk/rpc"; -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { //@ts-ignore Buffer exists window.Buffer = window.Buffer || Buffer; } + + + +/** + * Contract errors. + * + * Deliberately renumbered for the v1 interface so the error space is disjoint + * from the legacy (pre-1.0) contract's 1-9 range. A client decoding an error + * code < 100 is talking to a legacy wallet. + * + * Ranges: + * - 100-109: signer storage / management + * - 110-119: auth (`__check_auth`) + * - 120-129: WebAuthn (secp256r1) verification + */ export const Errors = { - 1: { message: "NotFound" }, - 2: { message: "AlreadyExists" }, - 3: { message: "MissingContext" }, - 4: { message: "SignerExpired" }, - 5: { message: "FailedSignerLimits" }, - 6: { message: "FailedPolicySignerLimits" }, - 7: { message: "SignatureKeyValueMismatch" }, - 8: { message: "ClientDataJsonChallengeIncorrect" }, - 9: { message: "JsonParseError" } + /** + * The requested signer does not exist on this smart wallet. + */ + 100: {message:"SignerNotFound"}, + /** + * `add_signer` was called with a signer key that already exists. + */ + 101: {message:"SignerAlreadyExists"}, + /** + * The signer's expiration timestamp is in the past. + */ + 102: {message:"SignerExpired"}, + /** + * No signer in the signatures map is permitted to authorize one of the + * requested auth contexts. + */ + 110: {message:"MissingContext"}, + /** + * A signature's variant does not match the stored signer it claims to be + * for (e.g. an Ed25519 signature submitted for a Policy signer key). + */ + 111: {message:"SignatureKeyValueMismatch"}, + /** + * clientDataJSON exceeds the 1024 byte parse buffer. + */ + 120: {message:"ClientDataJsonTooLarge"}, + /** + * clientDataJSON is not parseable JSON (or is missing required fields). + */ + 121: {message:"ClientDataJsonParseError"}, + /** + * The challenge in clientDataJSON does not match the base64url-encoded + * signature payload. This binds the WebAuthn assertion to the Soroban + * authorization entry and MUST NOT be weakened. + */ + 122: {message:"ClientDataJsonChallengeIncorrect"}, + /** + * clientDataJSON `type` is not "webauthn.get". + */ + 123: {message:"InvalidWebAuthnType"}, + /** + * authenticatorData is shorter than the WebAuthn minimum of 37 bytes + * (rpIdHash 32 + flags 1 + signCount 4). + */ + 124: {message:"InvalidAuthenticatorData"}, + /** + * The authenticator did not set the User Present (UP) flag. User + * Verification (UV) is deliberately NOT required; requiring UP only + * keeps silent, non-interactive assertions out while remaining + * compatible with authenticators that don't do user verification. + */ + 125: {message:"UserPresenceRequired"} } -export type SignerExpiration = readonly [Option]; + +/** + * Full signer description used by `__constructor`, `add_signer` and + * `update_signer`. + */ +export type Signer = {tag: "Policy", values: readonly [string, SignerExpiration, SignerLimits, SignerStorage]} | {tag: "Ed25519", values: readonly [Buffer, SignerExpiration, SignerLimits, SignerStorage]} | {tag: "Secp256r1", values: readonly [Buffer, Buffer, SignerExpiration, SignerLimits, SignerStorage]}; + +/** + * A signature entry in the signatures map. `Policy` carries no signature + * material: inclusion of the policy key authorizes an on-chain `policy__` + * check instead. + */ +export type Signature = {tag: "Policy", values: void} | {tag: "Ed25519", values: readonly [Buffer]} | {tag: "Secp256r1", values: readonly [Secp256r1Signature]}; + +/** + * Storage key identifying a signer. Secp256r1 carries the WebAuthn + * credential id (`keyId`). + */ +export type SignerKey = {tag: "Policy", values: readonly [string]} | {tag: "Ed25519", values: readonly [Buffer]} | {tag: "Secp256r1", values: readonly [Buffer]}; + +/** + * Stored signer value. Secp256r1 carries the SEC-1 uncompressed public key. + */ +export type SignerVal = {tag: "Policy", values: readonly [SignerExpiration, SignerLimits]} | {tag: "Ed25519", values: readonly [SignerExpiration, SignerLimits]} | {tag: "Secp256r1", values: readonly [Buffer, SignerExpiration, SignerLimits]}; + +/** + * The `__check_auth` signature object: a map of signer keys to signatures. + * Map ordering is the host's ScVal ordering. EVERY entry must verify (pass + * 2 of `__check_auth`) — include only signatures that are needed. + */ +export type Signatures = readonly [Map]; + +/** + * Restrictions on which auth contexts a signer may authorize. + * + * - `None`: unlimited. The signer can authorize anything, including + * `CreateContract*` (deploy) contexts and this wallet's own admin + * functions. + * - `Some(empty map)`: NO permissions (fail-closed). The signer can authorize + * nothing except removing itself (see below). v1 breaking change: pre-1.0 + * an empty map meant unlimited, leaving two unlimited encodings and no + * "none" encoding. + * - `Some({address -> None})`: the signer may authorize any invocation of + * contract `address`, with no co-signers required. + * - `Some({address -> Some([keys])})`: the signer may authorize invocations + * of contract `address` only if every listed key also passes: non-policy + * keys must be present in the transaction's signatures map (and therefore + * fully verified in pass 2 of `__check_auth`); policy keys are invoked via + * `policy__` for the specific context (and, if the policy key is also + * stored on this wallet, its own stored limits are verified recursively, + * bounded by a fixed depth guard). + * + * Note + */ export type SignerLimits = readonly [Option>>>]; -export type SignerStorage = { tag: "Persistent", values: void } | { tag: "Temporary", values: void }; -export type Signer = { tag: "Policy", values: readonly [string, SignerExpiration, SignerLimits, SignerStorage] } | { tag: "Ed25519", values: readonly [Buffer, SignerExpiration, SignerLimits, SignerStorage] } | { tag: "Secp256r1", values: readonly [Buffer, Buffer, SignerExpiration, SignerLimits, SignerStorage] }; -export type SignerKey = { tag: "Policy", values: readonly [string] } | { tag: "Ed25519", values: readonly [Buffer] } | { tag: "Secp256r1", values: readonly [Buffer] }; -export type SignerVal = { tag: "Policy", values: readonly [SignerExpiration, SignerLimits] } | { tag: "Ed25519", values: readonly [SignerExpiration, SignerLimits] } | { tag: "Secp256r1", values: readonly [Buffer, SignerExpiration, SignerLimits] }; +/** + * Which durability a signer entry is stored under. At most one entry exists + * per signer key; lookups check Temporary before Persistent. + */ +export type SignerStorage = {tag: "Persistent", values: void} | {tag: "Temporary", values: void}; + +/** + * Optional expiration for a signer as a UNIX timestamp in seconds, INCLUSIVE: + * the signer is valid while `ledger timestamp <= expiration` and expired once + * `ledger timestamp > expiration`. `None` never expires. + * + * v1 breaking change: this was a ledger sequence number pre-1.0. Timestamps + * don't drift with changes to ledger close time (e.g. CAP-0070 dynamic + * timing), which ledger-sequence expirations did. + */ +export type SignerExpiration = readonly [Option]; + + +/** + * A WebAuthn assertion over the Soroban authorization payload. The signed + * message is `authenticator_data || sha256(client_data_json)` and the + * payload binding lives in clientDataJSON's `challenge` field. + */ export interface Secp256r1Signature { authenticator_data: Buffer; client_data_json: Buffer; signature: Buffer; } -export type Signature = { tag: "Policy", values: void } | { tag: "Ed25519", values: readonly [Buffer] } | { tag: "Secp256r1", values: readonly [Secp256r1Signature] }; -export type Signatures = readonly [Map]; -export interface Client { - /** - * Construct and simulate a add_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - add_signer: ({ signer }: { signer: Signer }, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + +export interface Client { /** - * Construct and simulate a update_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Construct and simulate a upgrade transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - update_signer: ({ signer }: { signer: Signer }, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; + upgrade: ({new_wasm_hash}: {new_wasm_hash: Buffer}, options?: MethodOptions) => Promise>> - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; + /** + * Construct and simulate a add_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + add_signer: ({signer}: {signer: Signer}, options?: MethodOptions) => Promise>> - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + /** + * Construct and simulate a get_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + get_signer: ({signer_key}: {signer_key: SignerKey}, options?: MethodOptions) => Promise>> /** * Construct and simulate a remove_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - remove_signer: ({ signer_key }: { signer_key: SignerKey }, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> - - /** - * Construct and simulate a update_contract_code transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - update_contract_code: ({ hash }: { hash: Buffer }, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + remove_signer: ({signer_key}: {signer_key: SignerKey}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a update_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + update_signer: ({signer}: {signer: Signer}, options?: MethodOptions) => Promise>> + } export class Client extends ContractClient { static async deploy( - /** Constructor/Initialization Args for the contract's `__constructor` method */ - { signer }: { signer: Signer }, + /** Constructor/Initialization Args for the contract's `__constructor` method */ + {signer}: {signer: Signer}, /** Options for initializing a Client as well as for calling a method, with extras specific to deploying. */ options: MethodOptions & Omit & { @@ -136,36 +228,41 @@ export class Client extends ContractClient { salt?: Buffer | Uint8Array; /** The format used to decode `wasmHash`, if it's provided as a string. */ format?: "hex" | "base64"; - address?: string; } ): Promise> { - return ContractClient.deploy({ signer }, options) + return ContractClient.deploy({signer}, options) } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec(["AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAACQAAAAAAAAAITm90Rm91bmQAAAABAAAAAAAAAA1BbHJlYWR5RXhpc3RzAAAAAAAAAgAAAAAAAAAOTWlzc2luZ0NvbnRleHQAAAAAAAMAAAAAAAAADVNpZ25lckV4cGlyZWQAAAAAAAAEAAAAAAAAABJGYWlsZWRTaWduZXJMaW1pdHMAAAAAAAUAAAAAAAAAGEZhaWxlZFBvbGljeVNpZ25lckxpbWl0cwAAAAYAAAAAAAAAGVNpZ25hdHVyZUtleVZhbHVlTWlzbWF0Y2gAAAAAAAAHAAAAAAAAACBDbGllbnREYXRhSnNvbkNoYWxsZW5nZUluY29ycmVjdAAAAAgAAAAAAAAADkpzb25QYXJzZUVycm9yAAAAAAAJ", - "AAAAAQAAAAAAAAAAAAAAEFNpZ25lckV4cGlyYXRpb24AAAABAAAAAAAAAAEwAAAAAAAD6AAAAAQ=", - "AAAAAQAAAAAAAAAAAAAADFNpZ25lckxpbWl0cwAAAAEAAAAAAAAAATAAAAAAAAPoAAAD7AAAABMAAAPoAAAD6gAAB9AAAAAJU2lnbmVyS2V5AAAA", - "AAAAAgAAAAAAAAAAAAAADVNpZ25lclN0b3JhZ2UAAAAAAAACAAAAAAAAAAAAAAAKUGVyc2lzdGVudAAAAAAAAAAAAAAAAAAJVGVtcG9yYXJ5AAAA", - "AAAAAgAAAAAAAAAAAAAABlNpZ25lcgAAAAAAAwAAAAEAAAAAAAAABlBvbGljeQAAAAAABAAAABMAAAfQAAAAEFNpZ25lckV4cGlyYXRpb24AAAfQAAAADFNpZ25lckxpbWl0cwAAB9AAAAANU2lnbmVyU3RvcmFnZQAAAAAAAAEAAAAAAAAAB0VkMjU1MTkAAAAABAAAA+4AAAAgAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAABAAAAAAAAAAlTZWNwMjU2cjEAAAAAAAAFAAAADgAAA+4AAABBAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAA=", - "AAAAAgAAAAAAAAAAAAAACVNpZ25lcktleQAAAAAAAAMAAAABAAAAAAAAAAZQb2xpY3kAAAAAAAEAAAATAAAAAQAAAAAAAAAHRWQyNTUxOQAAAAABAAAD7gAAACAAAAABAAAAAAAAAAlTZWNwMjU2cjEAAAAAAAABAAAADg==", - "AAAAAgAAAAAAAAAAAAAACVNpZ25lclZhbAAAAAAAAAMAAAABAAAAAAAAAAZQb2xpY3kAAAAAAAIAAAfQAAAAEFNpZ25lckV4cGlyYXRpb24AAAfQAAAADFNpZ25lckxpbWl0cwAAAAEAAAAAAAAAB0VkMjU1MTkAAAAAAgAAB9AAAAAQU2lnbmVyRXhwaXJhdGlvbgAAB9AAAAAMU2lnbmVyTGltaXRzAAAAAQAAAAAAAAAJU2VjcDI1NnIxAAAAAAAAAwAAA+4AAABBAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHM=", - "AAAAAQAAAAAAAAAAAAAAElNlY3AyNTZyMVNpZ25hdHVyZQAAAAAAAwAAAAAAAAASYXV0aGVudGljYXRvcl9kYXRhAAAAAAAOAAAAAAAAABBjbGllbnRfZGF0YV9qc29uAAAADgAAAAAAAAAJc2lnbmF0dXJlAAAAAAAD7gAAAEA=", - "AAAAAgAAAAAAAAAAAAAACVNpZ25hdHVyZQAAAAAAAAMAAAAAAAAAAAAAAAZQb2xpY3kAAAAAAAEAAAAAAAAAB0VkMjU1MTkAAAAAAQAAA+4AAABAAAAAAQAAAAAAAAAJU2VjcDI1NnIxAAAAAAAAAQAAB9AAAAASU2VjcDI1NnIxU2lnbmF0dXJlAAA=", - "AAAAAQAAAAAAAAAAAAAAClNpZ25hdHVyZXMAAAAAAAEAAAAAAAAAATAAAAAAAAPsAAAH0AAAAAlTaWduZXJLZXkAAAAAAAfQAAAACVNpZ25hdHVyZQAAAA==", + new ContractSpec([ "AAAAAAAAAAAAAAAHdXBncmFkZQAAAAABAAAAAAAAAA1uZXdfd2FzbV9oYXNoAAAAAAAD7gAAACAAAAABAAAD6QAAAAIAAAAD", + "AAAAAAAAAAAAAAAKYWRkX3NpZ25lcgAAAAAAAQAAAAAAAAAGc2lnbmVyAAAAAAfQAAAABlNpZ25lcgAAAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAAAAAAAKZ2V0X3NpZ25lcgAAAAAAAQAAAAAAAAAKc2lnbmVyX2tleQAAAAAH0AAAAAlTaWduZXJLZXkAAAAAAAABAAAD6AAAB9AAAAAJU2lnbmVyVmFsAAAA", + "AAAAAAAAAAAAAAAMX19jaGVja19hdXRoAAAAAwAAAAAAAAARc2lnbmF0dXJlX3BheWxvYWQAAAAAAAPuAAAAIAAAAAAAAAAKc2lnbmF0dXJlcwAAAAAH0AAAAApTaWduYXR1cmVzAAAAAAAAAAAADWF1dGhfY29udGV4dHMAAAAAAAPqAAAH0AAAAAdDb250ZXh0AAAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAEAAAAAAAAABnNpZ25lcgAAAAAH0AAAAAZTaWduZXIAAAAAAAA=", - "AAAAAAAAAAAAAAAKYWRkX3NpZ25lcgAAAAAAAQAAAAAAAAAGc2lnbmVyAAAAAAfQAAAABlNpZ25lcgAAAAAAAA==", - "AAAAAAAAAAAAAAANdXBkYXRlX3NpZ25lcgAAAAAAAAEAAAAAAAAABnNpZ25lcgAAAAAH0AAAAAZTaWduZXIAAAAAAAA=", - "AAAAAAAAAAAAAAANcmVtb3ZlX3NpZ25lcgAAAAAAAAEAAAAAAAAACnNpZ25lcl9rZXkAAAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAA==", - "AAAAAAAAAAAAAAAUdXBkYXRlX2NvbnRyYWN0X2NvZGUAAAABAAAAAAAAAARoYXNoAAAD7gAAACAAAAAA", - "AAAAAAAAAAAAAAAMX19jaGVja19hdXRoAAAAAwAAAAAAAAARc2lnbmF0dXJlX3BheWxvYWQAAAAAAAPuAAAAIAAAAAAAAAAKc2lnbmF0dXJlcwAAAAAH0AAAAApTaWduYXR1cmVzAAAAAAAAAAAADWF1dGhfY29udGV4dHMAAAAAAAPqAAAH0AAAAAdDb250ZXh0AAAAAAEAAAPpAAAD7QAAAAAAAAAD"]), + "AAAAAAAAAAAAAAANcmVtb3ZlX3NpZ25lcgAAAAAAAAEAAAAAAAAACnNpZ25lcl9rZXkAAAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAAAAAAANdXBkYXRlX3NpZ25lcgAAAAAAAAEAAAAAAAAABnNpZ25lcgAAAAAH0AAAAAZTaWduZXIAAAAAAAEAAAPpAAAAAgAAAAM=", + "AAAABAAAAVBDb250cmFjdCBlcnJvcnMuCgpEZWxpYmVyYXRlbHkgcmVudW1iZXJlZCBmb3IgdGhlIHYxIGludGVyZmFjZSBzbyB0aGUgZXJyb3Igc3BhY2UgaXMgZGlzam9pbnQKZnJvbSB0aGUgbGVnYWN5IChwcmUtMS4wKSBjb250cmFjdCdzIDEtOSByYW5nZS4gQSBjbGllbnQgZGVjb2RpbmcgYW4gZXJyb3IKY29kZSA8IDEwMCBpcyB0YWxraW5nIHRvIGEgbGVnYWN5IHdhbGxldC4KClJhbmdlczoKLSAxMDAtMTA5OiBzaWduZXIgc3RvcmFnZSAvIG1hbmFnZW1lbnQKLSAxMTAtMTE5OiBhdXRoIChgX19jaGVja19hdXRoYCkKLSAxMjAtMTI5OiBXZWJBdXRobiAoc2VjcDI1NnIxKSB2ZXJpZmljYXRpb24AAAAAAAAABUVycm9yAAAAAAAACwAAADlUaGUgcmVxdWVzdGVkIHNpZ25lciBkb2VzIG5vdCBleGlzdCBvbiB0aGlzIHNtYXJ0IHdhbGxldC4AAAAAAAAOU2lnbmVyTm90Rm91bmQAAAAAAGQAAAA+YGFkZF9zaWduZXJgIHdhcyBjYWxsZWQgd2l0aCBhIHNpZ25lciBrZXkgdGhhdCBhbHJlYWR5IGV4aXN0cy4AAAAAABNTaWduZXJBbHJlYWR5RXhpc3RzAAAAAGUAAAAxVGhlIHNpZ25lcidzIGV4cGlyYXRpb24gdGltZXN0YW1wIGlzIGluIHRoZSBwYXN0LgAAAAAAAA1TaWduZXJFeHBpcmVkAAAAAAAAZgAAAF1ObyBzaWduZXIgaW4gdGhlIHNpZ25hdHVyZXMgbWFwIGlzIHBlcm1pdHRlZCB0byBhdXRob3JpemUgb25lIG9mIHRoZQpyZXF1ZXN0ZWQgYXV0aCBjb250ZXh0cy4AAAAAAAAOTWlzc2luZ0NvbnRleHQAAAAAAG4AAACJQSBzaWduYXR1cmUncyB2YXJpYW50IGRvZXMgbm90IG1hdGNoIHRoZSBzdG9yZWQgc2lnbmVyIGl0IGNsYWltcyB0byBiZQpmb3IgKGUuZy4gYW4gRWQyNTUxOSBzaWduYXR1cmUgc3VibWl0dGVkIGZvciBhIFBvbGljeSBzaWduZXIga2V5KS4AAAAAAAAZU2lnbmF0dXJlS2V5VmFsdWVNaXNtYXRjaAAAAAAAAG8AAAAyY2xpZW50RGF0YUpTT04gZXhjZWVkcyB0aGUgMTAyNCBieXRlIHBhcnNlIGJ1ZmZlci4AAAAAABZDbGllbnREYXRhSnNvblRvb0xhcmdlAAAAAAB4AAAARWNsaWVudERhdGFKU09OIGlzIG5vdCBwYXJzZWFibGUgSlNPTiAob3IgaXMgbWlzc2luZyByZXF1aXJlZCBmaWVsZHMpLgAAAAAAABhDbGllbnREYXRhSnNvblBhcnNlRXJyb3IAAAB5AAAAtlRoZSBjaGFsbGVuZ2UgaW4gY2xpZW50RGF0YUpTT04gZG9lcyBub3QgbWF0Y2ggdGhlIGJhc2U2NHVybC1lbmNvZGVkCnNpZ25hdHVyZSBwYXlsb2FkLiBUaGlzIGJpbmRzIHRoZSBXZWJBdXRobiBhc3NlcnRpb24gdG8gdGhlIFNvcm9iYW4KYXV0aG9yaXphdGlvbiBlbnRyeSBhbmQgTVVTVCBOT1QgYmUgd2Vha2VuZWQuAAAAAAAgQ2xpZW50RGF0YUpzb25DaGFsbGVuZ2VJbmNvcnJlY3QAAAB6AAAALGNsaWVudERhdGFKU09OIGB0eXBlYCBpcyBub3QgIndlYmF1dGhuLmdldCIuAAAAE0ludmFsaWRXZWJBdXRoblR5cGUAAAAAewAAAGlhdXRoZW50aWNhdG9yRGF0YSBpcyBzaG9ydGVyIHRoYW4gdGhlIFdlYkF1dGhuIG1pbmltdW0gb2YgMzcgYnl0ZXMKKHJwSWRIYXNoIDMyICsgZmxhZ3MgMSArIHNpZ25Db3VudCA0KS4AAAAAAAAYSW52YWxpZEF1dGhlbnRpY2F0b3JEYXRhAAAAfAAAAP1UaGUgYXV0aGVudGljYXRvciBkaWQgbm90IHNldCB0aGUgVXNlciBQcmVzZW50IChVUCkgZmxhZy4gVXNlcgpWZXJpZmljYXRpb24gKFVWKSBpcyBkZWxpYmVyYXRlbHkgTk9UIHJlcXVpcmVkOyByZXF1aXJpbmcgVVAgb25seQprZWVwcyBzaWxlbnQsIG5vbi1pbnRlcmFjdGl2ZSBhc3NlcnRpb25zIG91dCB3aGlsZSByZW1haW5pbmcKY29tcGF0aWJsZSB3aXRoIGF1dGhlbnRpY2F0b3JzIHRoYXQgZG9uJ3QgZG8gdXNlciB2ZXJpZmljYXRpb24uAAAAAAAAFFVzZXJQcmVzZW5jZVJlcXVpcmVkAAAAfQ==", + "AAAAAgAAAFJGdWxsIHNpZ25lciBkZXNjcmlwdGlvbiB1c2VkIGJ5IGBfX2NvbnN0cnVjdG9yYCwgYGFkZF9zaWduZXJgIGFuZApgdXBkYXRlX3NpZ25lcmAuAAAAAAAAAAAABlNpZ25lcgAAAAAAAwAAAAEAAAAAAAAABlBvbGljeQAAAAAABAAAABMAAAfQAAAAEFNpZ25lckV4cGlyYXRpb24AAAfQAAAADFNpZ25lckxpbWl0cwAAB9AAAAANU2lnbmVyU3RvcmFnZQAAAAAAAAEAAAAAAAAAB0VkMjU1MTkAAAAABAAAA+4AAAAgAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAABAAAAAAAAAAlTZWNwMjU2cjEAAAAAAAAFAAAADgAAA+4AAABBAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAA=", + "AAAAAgAAAJ1BIHNpZ25hdHVyZSBlbnRyeSBpbiB0aGUgc2lnbmF0dXJlcyBtYXAuIGBQb2xpY3lgIGNhcnJpZXMgbm8gc2lnbmF0dXJlCm1hdGVyaWFsOiBpbmNsdXNpb24gb2YgdGhlIHBvbGljeSBrZXkgYXV0aG9yaXplcyBhbiBvbi1jaGFpbiBgcG9saWN5X19gCmNoZWNrIGluc3RlYWQuAAAAAAAAAAAAAAlTaWduYXR1cmUAAAAAAAADAAAAAAAAAAAAAAAGUG9saWN5AAAAAAABAAAAAAAAAAdFZDI1NTE5AAAAAAEAAAPuAAAAQAAAAAEAAAAAAAAACVNlY3AyNTZyMQAAAAAAAAEAAAfQAAAAElNlY3AyNTZyMVNpZ25hdHVyZQAA", + "AAAAAgAAAFlTdG9yYWdlIGtleSBpZGVudGlmeWluZyBhIHNpZ25lci4gU2VjcDI1NnIxIGNhcnJpZXMgdGhlIFdlYkF1dGhuCmNyZWRlbnRpYWwgaWQgKGBrZXlJZGApLgAAAAAAAAAAAAAJU2lnbmVyS2V5AAAAAAAAAwAAAAEAAAAAAAAABlBvbGljeQAAAAAAAQAAABMAAAABAAAAAAAAAAdFZDI1NTE5AAAAAAEAAAPuAAAAIAAAAAEAAAAAAAAACVNlY3AyNTZyMQAAAAAAAAEAAAAO", + "AAAAAgAAAElTdG9yZWQgc2lnbmVyIHZhbHVlLiBTZWNwMjU2cjEgY2FycmllcyB0aGUgU0VDLTEgdW5jb21wcmVzc2VkIHB1YmxpYyBrZXkuAAAAAAAAAAAAAAlTaWduZXJWYWwAAAAAAAADAAAAAQAAAAAAAAAGUG9saWN5AAAAAAACAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAABAAAAAAAAAAdFZDI1NTE5AAAAAAIAAAfQAAAAEFNpZ25lckV4cGlyYXRpb24AAAfQAAAADFNpZ25lckxpbWl0cwAAAAEAAAAAAAAACVNlY3AyNTZyMQAAAAAAAAMAAAPuAAAAQQAAB9AAAAAQU2lnbmVyRXhwaXJhdGlvbgAAB9AAAAAMU2lnbmVyTGltaXRz", + "AAAAAQAAANNUaGUgYF9fY2hlY2tfYXV0aGAgc2lnbmF0dXJlIG9iamVjdDogYSBtYXAgb2Ygc2lnbmVyIGtleXMgdG8gc2lnbmF0dXJlcy4KTWFwIG9yZGVyaW5nIGlzIHRoZSBob3N0J3MgU2NWYWwgb3JkZXJpbmcuIEVWRVJZIGVudHJ5IG11c3QgdmVyaWZ5IChwYXNzCjIgb2YgYF9fY2hlY2tfYXV0aGApIOKAlCBpbmNsdWRlIG9ubHkgc2lnbmF0dXJlcyB0aGF0IGFyZSBuZWVkZWQuAAAAAAAAAAAKU2lnbmF0dXJlcwAAAAAAAQAAAAAAAAABMAAAAAAAA+wAAAfQAAAACVNpZ25lcktleQAAAAAAB9AAAAAJU2lnbmF0dXJlAAAA", + "AAAAAQAABABSZXN0cmljdGlvbnMgb24gd2hpY2ggYXV0aCBjb250ZXh0cyBhIHNpZ25lciBtYXkgYXV0aG9yaXplLgoKLSBgTm9uZWA6IHVubGltaXRlZC4gVGhlIHNpZ25lciBjYW4gYXV0aG9yaXplIGFueXRoaW5nLCBpbmNsdWRpbmcKYENyZWF0ZUNvbnRyYWN0KmAgKGRlcGxveSkgY29udGV4dHMgYW5kIHRoaXMgd2FsbGV0J3Mgb3duIGFkbWluCmZ1bmN0aW9ucy4KLSBgU29tZShlbXB0eSBtYXApYDogTk8gcGVybWlzc2lvbnMgKGZhaWwtY2xvc2VkKS4gVGhlIHNpZ25lciBjYW4gYXV0aG9yaXplCm5vdGhpbmcgZXhjZXB0IHJlbW92aW5nIGl0c2VsZiAoc2VlIGJlbG93KS4gdjEgYnJlYWtpbmcgY2hhbmdlOiBwcmUtMS4wCmFuIGVtcHR5IG1hcCBtZWFudCB1bmxpbWl0ZWQsIGxlYXZpbmcgdHdvIHVubGltaXRlZCBlbmNvZGluZ3MgYW5kIG5vCiJub25lIiBlbmNvZGluZy4KLSBgU29tZSh7YWRkcmVzcyAtPiBOb25lfSlgOiB0aGUgc2lnbmVyIG1heSBhdXRob3JpemUgYW55IGludm9jYXRpb24gb2YKY29udHJhY3QgYGFkZHJlc3NgLCB3aXRoIG5vIGNvLXNpZ25lcnMgcmVxdWlyZWQuCi0gYFNvbWUoe2FkZHJlc3MgLT4gU29tZShba2V5c10pfSlgOiB0aGUgc2lnbmVyIG1heSBhdXRob3JpemUgaW52b2NhdGlvbnMKb2YgY29udHJhY3QgYGFkZHJlc3NgIG9ubHkgaWYgZXZlcnkgbGlzdGVkIGtleSBhbHNvIHBhc3Nlczogbm9uLXBvbGljeQprZXlzIG11c3QgYmUgcHJlc2VudCBpbiB0aGUgdHJhbnNhY3Rpb24ncyBzaWduYXR1cmVzIG1hcCAoYW5kIHRoZXJlZm9yZQpmdWxseSB2ZXJpZmllZCBpbiBwYXNzIDIgb2YgYF9fY2hlY2tfYXV0aGApOyBwb2xpY3kga2V5cyBhcmUgaW52b2tlZCB2aWEKYHBvbGljeV9fYCBmb3IgdGhlIHNwZWNpZmljIGNvbnRleHQgKGFuZCwgaWYgdGhlIHBvbGljeSBrZXkgaXMgYWxzbwpzdG9yZWQgb24gdGhpcyB3YWxsZXQsIGl0cyBvd24gc3RvcmVkIGxpbWl0cyBhcmUgdmVyaWZpZWQgcmVjdXJzaXZlbHksCmJvdW5kZWQgYnkgYSBmaXhlZCBkZXB0aCBndWFyZCkuCgpOb3RlAAAAAAAAAAxTaWduZXJMaW1pdHMAAAABAAAAAAAAAAEwAAAAAAAD6AAAA+wAAAATAAAD6AAAA+oAAAfQAAAACVNpZ25lcktleQAAAA==", + "AAAAAgAAAIRXaGljaCBkdXJhYmlsaXR5IGEgc2lnbmVyIGVudHJ5IGlzIHN0b3JlZCB1bmRlci4gQXQgbW9zdCBvbmUgZW50cnkgZXhpc3RzCnBlciBzaWduZXIga2V5OyBsb29rdXBzIGNoZWNrIFRlbXBvcmFyeSBiZWZvcmUgUGVyc2lzdGVudC4AAAAAAAAADVNpZ25lclN0b3JhZ2UAAAAAAAACAAAAAAAAAAAAAAAKUGVyc2lzdGVudAAAAAAAAAAAAAAAAAAJVGVtcG9yYXJ5AAAA", + "AAAAAQAAAY5PcHRpb25hbCBleHBpcmF0aW9uIGZvciBhIHNpZ25lciBhcyBhIFVOSVggdGltZXN0YW1wIGluIHNlY29uZHMsIElOQ0xVU0lWRToKdGhlIHNpZ25lciBpcyB2YWxpZCB3aGlsZSBgbGVkZ2VyIHRpbWVzdGFtcCA8PSBleHBpcmF0aW9uYCBhbmQgZXhwaXJlZCBvbmNlCmBsZWRnZXIgdGltZXN0YW1wID4gZXhwaXJhdGlvbmAuIGBOb25lYCBuZXZlciBleHBpcmVzLgoKdjEgYnJlYWtpbmcgY2hhbmdlOiB0aGlzIHdhcyBhIGxlZGdlciBzZXF1ZW5jZSBudW1iZXIgcHJlLTEuMC4gVGltZXN0YW1wcwpkb24ndCBkcmlmdCB3aXRoIGNoYW5nZXMgdG8gbGVkZ2VyIGNsb3NlIHRpbWUgKGUuZy4gQ0FQLTAwNzAgZHluYW1pYwp0aW1pbmcpLCB3aGljaCBsZWRnZXItc2VxdWVuY2UgZXhwaXJhdGlvbnMgZGlkLgAAAAAAAAAAABBTaWduZXJFeHBpcmF0aW9uAAAAAQAAAAAAAAABMAAAAAAAA+gAAAAG", + "AAAAAQAAAMhBIFdlYkF1dGhuIGFzc2VydGlvbiBvdmVyIHRoZSBTb3JvYmFuIGF1dGhvcml6YXRpb24gcGF5bG9hZC4gVGhlIHNpZ25lZAptZXNzYWdlIGlzIGBhdXRoZW50aWNhdG9yX2RhdGEgfHwgc2hhMjU2KGNsaWVudF9kYXRhX2pzb24pYCBhbmQgdGhlCnBheWxvYWQgYmluZGluZyBsaXZlcyBpbiBjbGllbnREYXRhSlNPTidzIGBjaGFsbGVuZ2VgIGZpZWxkLgAAAAAAAAASU2VjcDI1NnIxU2lnbmF0dXJlAAAAAAADAAAAAAAAABJhdXRoZW50aWNhdG9yX2RhdGEAAAAAAA4AAAAAAAAAEGNsaWVudF9kYXRhX2pzb24AAAAOAAAAAAAAAAlzaWduYXR1cmUAAAAAAAPuAAAAQA==", + "AAAABQAAASBUaGUgY29udHJhY3QncyB3YXNtIHdhcyByZXBsYWNlZCB2aWEgYHVwZ3JhZGVgLiBgb2xkX2hhc2hgIGlzIGBOb25lYCBvbiBhCndhbGxldCdzIGZpcnN0LWV2ZXIgdXBncmFkZTogdGhlIGhvc3QgZXhwb3NlcyBubyB3YXkgZm9yIGEgY29udHJhY3QgdG8KcmVhZCBpdHMgb3duIGV4ZWN1dGFibGUgaGFzaCwgc28gdGhlIHdhbGxldCBjYWNoZXMgdGhlIGhhc2ggaW4gaW5zdGFuY2UKc3RvcmFnZSBhdCBlYWNoIHVwZ3JhZGUgYW5kIHRoZSBnZW5lc2lzIGhhc2ggaXMgdW5rbm93YWJsZSBpbi1jb250cmFjdC4AAAAAAAAACFVwZ3JhZGVkAAAAAQAAAAh1cGdyYWRlZAAAAAIAAAAAAAAACG9sZF9oYXNoAAAD6AAAA+4AAAAgAAAAAAAAAAAAAAAIbmV3X2hhc2gAAAPuAAAAIAAAAAAAAAAC", + "AAAABQAAADlBIHNpZ25lciB3YXMgYWRkZWQgKHZpYSBgX19jb25zdHJ1Y3RvcmAgb3IgYGFkZF9zaWduZXJgKS4AAAAAAAAAAAAAC1NpZ25lckFkZGVkAAAAAAEAAAAMc2lnbmVyX2FkZGVkAAAAAwAAAAAAAAADa2V5AAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAQAAAAAAAAADdmFsAAAAB9AAAAAJU2lnbmVyVmFsAAAAAAAAAAAAAAAAAAAHc3RvcmFnZQAAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAAAAAAAAg==", + "AAAABQAAAGFBIHNpZ25lciB3YXMgcmVtb3ZlZCB2aWEgYHJlbW92ZV9zaWduZXJgLiBgc3RvcmFnZWAgaXMgdGhlIGR1cmFiaWxpdHkgdGhlCmVudHJ5IHdhcyByZW1vdmVkIGZyb20uAAAAAAAAAAAAAA1TaWduZXJSZW1vdmVkAAAAAAAAAQAAAA5zaWduZXJfcmVtb3ZlZAAAAAAAAgAAAAAAAAADa2V5AAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAQAAAAAAAAAHc3RvcmFnZQAAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAAAAAAAAg==", + "AAAABQAAADRBbiBleGlzdGluZyBzaWduZXIgd2FzIG1vZGlmaWVkIHZpYSBgdXBkYXRlX3NpZ25lcmAuAAAAAAAAAA1TaWduZXJVcGRhdGVkAAAAAAAAAQAAAA5zaWduZXJfdXBkYXRlZAAAAAAABAAAAAAAAAADa2V5AAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAQAAAAAAAAADdmFsAAAAB9AAAAAJU2lnbmVyVmFsAAAAAAAAAAAAAAAAAAAHc3RvcmFnZQAAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAAAAAAAAAAAAAtvbGRfc3RvcmFnZQAAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAAAAAAAAg==" ]), options ) } public readonly fromJSON = { - add_signer: this.txFromJSON, - update_signer: this.txFromJSON, - remove_signer: this.txFromJSON, - update_contract_code: this.txFromJSON + upgrade: this.txFromJSON>, + add_signer: this.txFromJSON>, + get_signer: this.txFromJSON>, + remove_signer: this.txFromJSON>, + update_signer: this.txFromJSON> } } \ No newline at end of file diff --git a/scripts/bindings/build.sh b/scripts/bindings/build.sh new file mode 100755 index 0000000..04fbb55 --- /dev/null +++ b/scripts/bindings/build.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Regenerate the passkey-kit-sdk bindings from the canonical smart-wallet WASM. +# +# The canonical hash is THE source of truth (docs/deployments-testnet-*.md). The +# WASM is fetched by hash from testnet, bindings are generated to a temp dir with +# the pinned Stellar CLI, and only the generated `src/index.ts` (+ README) are +# copied into the package — the B1 packaging (package.json, tsconfig, dist build) +# is preserved. NEVER hand-edit the generated bindings; add post-gen steps here. +# +# Usage: bash scripts/bindings/build.sh +set -euo pipefail + +# Canonical smart-wallet WASM hash — keep in sync with the deployments manifest +# (docs/deployments-testnet-2026-07-11.md). Pinned Stellar CLI: 27.0.0. +CANONICAL_HASH="9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe" +NETWORK="testnet" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PKG_SRC="$ROOT/packages/passkey-kit-sdk/src/index.ts" +PKG_README="$ROOT/packages/passkey-kit-sdk/README.md" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +echo "Fetching canonical WASM ${CANONICAL_HASH} from ${NETWORK}…" +stellar contract fetch --wasm-hash "$CANONICAL_HASH" --network "$NETWORK" \ + --out-file "$TMP/smart-wallet.wasm" + +ACTUAL="$(shasum -a 256 "$TMP/smart-wallet.wasm" | awk '{print $1}')" +if [ "$ACTUAL" != "$CANONICAL_HASH" ]; then + echo "✗ Fetched WASM hash ($ACTUAL) != canonical ($CANONICAL_HASH)" >&2 + exit 1 +fi + +echo "Generating TypeScript bindings…" +stellar contract bindings typescript --wasm "$TMP/smart-wallet.wasm" \ + --overwrite --output-dir "$TMP/gen" >/dev/null + +echo "Applying post-gen patches (copy generated spec, preserve packaging)…" +cp "$TMP/gen/src/index.ts" "$PKG_SRC" +cp "$TMP/gen/README.md" "$PKG_README" + +echo "✓ Regenerated passkey-kit-sdk from canonical WASM ${CANONICAL_HASH}" +echo " Run 'pnpm --filter passkey-kit-sdk run build' to rebuild dist." diff --git a/scripts/bindings/verify.sh b/scripts/bindings/verify.sh new file mode 100755 index 0000000..010381b --- /dev/null +++ b/scripts/bindings/verify.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Bindings drift guard. +# +# Proves the committed passkey-kit-sdk bindings match the canonical smart-wallet +# WASM: fetch the WASM by its pinned hash, regenerate to a temp dir, and diff the +# ContractSpec base64 array (the semantic content — CLI formatting/comments are +# ignored) against the committed bindings. Fails the build/release on drift. +# +# Usage: bash scripts/bindings/verify.sh +set -euo pipefail + +CANONICAL_HASH="9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe" +NETWORK="testnet" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PKG_SRC="$ROOT/packages/passkey-kit-sdk/src/index.ts" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +echo "verify:bindings — canonical WASM ${CANONICAL_HASH} (${NETWORK})" + +stellar contract fetch --wasm-hash "$CANONICAL_HASH" --network "$NETWORK" \ + --out-file "$TMP/smart-wallet.wasm" + +ACTUAL="$(shasum -a 256 "$TMP/smart-wallet.wasm" | awk '{print $1}')" +if [ "$ACTUAL" != "$CANONICAL_HASH" ]; then + echo "✗ Fetched WASM hash ($ACTUAL) != canonical ($CANONICAL_HASH)" >&2 + exit 1 +fi + +stellar contract bindings typescript --wasm "$TMP/smart-wallet.wasm" \ + --overwrite --output-dir "$TMP/gen" >/dev/null + +# Extract the ContractSpec base64 string literals (>= 16 chars, base64 charset), +# in order. This is the wasm-determined spec, independent of CLI TS formatting. +extract_spec() { + grep -oE '"[A-Za-z0-9+/]{16,}={0,2}"' "$1" +} + +if diff -u \ + <(extract_spec "$PKG_SRC") \ + <(extract_spec "$TMP/gen/src/index.ts"); then + echo "✓ Committed bindings match canonical WASM ${CANONICAL_HASH}" +else + echo "" >&2 + echo "✗ Bindings DRIFT from the canonical WASM." >&2 + echo " Regenerate with: bash scripts/bindings/build.sh" >&2 + echo " (then rebuild + review the diff before committing)" >&2 + exit 1 +fi diff --git a/src/contract-errors.test.ts b/src/contract-errors.test.ts index 3eed3e1..f3edcbe 100644 --- a/src/contract-errors.test.ts +++ b/src/contract-errors.test.ts @@ -17,43 +17,49 @@ import { } from "./errors.js"; describe("decodeContractError", () => { - it("decodes a contract error from a diagnostic string", () => { + it("decodes a v1 contract error from a diagnostic string", () => { const error = decodeContractError( - "HostError: Error(Contract, #4) ... SignerExpired" + "HostError: Error(Contract, #102) ... SignerExpired" ); expect(error).toBeInstanceOf(ContractError); - expect(error?.contractCode).toBe(4); + expect(error?.contractCode).toBe(102); expect(error?.contractErrorName).toBe("SignerExpired"); expect(error?.family).toBe("SmartWallet"); expect(error?.code).toBe(PasskeyKitErrorCode.CONTRACT_ERROR); }); it.each([ - [1, "NotFound"], - [2, "AlreadyExists"], - [3, "MissingContext"], - [7, "SignatureKeyValueMismatch"], - [8, "ClientDataJsonChallengeIncorrect"], - [9, "JsonParseError"], - ])("decodes #%i as %s", (code, name) => { + [100, "SignerNotFound"], + [101, "SignerAlreadyExists"], + [110, "MissingContext"], + [122, "ClientDataJsonChallengeIncorrect"], + [123, "InvalidWebAuthnType"], + [125, "UserPresenceRequired"], + ])("decodes v1 #%i as %s", (code, name) => { const error = decodeContractError(`Error(Contract, #${code})`); expect(error?.contractCode).toBe(code); expect(error?.contractErrorName).toBe(name); expect(error?.family).toBe("SmartWallet"); }); + it("still decodes legacy (< 100) codes as SmartWalletLegacy", () => { + const error = decodeContractError("Error(Contract, #4)"); + expect(error?.contractErrorName).toBe("SignerExpired"); + expect(error?.family).toBe("SmartWalletLegacy"); + }); + it("handles whitespace variations in the marker", () => { - expect(decodeContractError("Error(Contract,#2)")?.contractErrorName).toBe( - "AlreadyExists" - ); - expect(decodeContractError("Error(Contract, #2)")?.contractErrorName).toBe( - "AlreadyExists" + expect(decodeContractError("Error(Contract,#101)")?.contractErrorName).toBe( + "SignerAlreadyExists" ); + expect( + decodeContractError("Error(Contract, #101)")?.contractErrorName + ).toBe("SignerAlreadyExists"); }); it("decodes from an Error object's message", () => { - const error = decodeContractError(new Error("Error(Contract, #5)")); - expect(error?.contractErrorName).toBe("FailedSignerLimits"); + const error = decodeContractError(new Error("Error(Contract, #111)")); + expect(error?.contractErrorName).toBe("SignatureKeyValueMismatch"); }); it("returns null when there is no contract marker", () => { @@ -70,9 +76,9 @@ describe("decodeContractError", () => { describe("contractErrorFromCode", () => { it("builds a ContractError for a known code", () => { - const error = contractErrorFromCode(6); + const error = contractErrorFromCode(120); expect(error).toBeInstanceOf(ContractError); - expect(error?.contractErrorName).toBe("FailedPolicySignerLimits"); + expect(error?.contractErrorName).toBe("ClientDataJsonTooLarge"); }); it("returns null for an unknown code", () => { @@ -120,7 +126,7 @@ describe("transaction failure helpers", () => { }); it("simulationFailure decodes a contract error when present", () => { - const failure = simulationFailure("Error(Contract, #4)"); + const failure = simulationFailure("Error(Contract, #102)"); expect(failure.error).toBeInstanceOf(ContractError); expect(failure.error.code).toBe(PasskeyKitErrorCode.CONTRACT_ERROR); }); diff --git a/src/contract-errors.ts b/src/contract-errors.ts index 2fb5ddb..1ceb6de 100644 --- a/src/contract-errors.ts +++ b/src/contract-errors.ts @@ -9,9 +9,10 @@ * `Errors` map; `contract-errors.test.ts` asserts this registry stays in sync * with it, so a bindings regen surfaces any drift. * - * NOTE: these are the CURRENT contract's 9 codes (1-9). The reworked contract - * deliberately renumbers and adds codes (spec #602); this registry is resynced - * to the regenerated bindings in B4. + * The v1 contract deliberately renumbered its error space to 100-129 so it is + * disjoint from the legacy (pre-1.0) 1-9 range: a decoded code < 100 means the + * client is talking to a legacy wallet. Both ranges are kept here so errors + * from legacy deployed wallets still decode (family `SmartWalletLegacy`). * * @packageDocumentation */ @@ -27,7 +28,7 @@ import type { TransactionFailure } from "./types.js"; /** * Contract families that expose custom error codes. */ -export type ContractErrorFamily = "SmartWallet"; +export type ContractErrorFamily = "SmartWallet" | "SmartWalletLegacy"; /** * A single contract error entry: code, enum variant name, family, and a @@ -56,15 +57,32 @@ export const CONTRACT_ERROR_REGISTRY: Readonly< Record > = Object.freeze( Object.fromEntries([ - entry(1, "NotFound", "SmartWallet", "The specified signer was not found."), - entry(2, "AlreadyExists", "SmartWallet", "The signer already exists on this wallet."), - entry(3, "MissingContext", "SmartWallet", "The signature did not cover a required authorization context."), - entry(4, "SignerExpired", "SmartWallet", "The signer has expired."), - entry(5, "FailedSignerLimits", "SmartWallet", "The signer is not permitted to authorize this context by its limits."), - entry(6, "FailedPolicySignerLimits", "SmartWallet", "A policy signer's limits rejected this context."), - entry(7, "SignatureKeyValueMismatch", "SmartWallet", "A signature entry's key and value did not match."), - entry(8, "ClientDataJsonChallengeIncorrect", "SmartWallet", "The WebAuthn clientDataJSON challenge did not match the signature payload."), - entry(9, "JsonParseError", "SmartWallet", "The WebAuthn clientDataJSON could not be parsed."), + // --- v1 SmartWallet (100-129) — source of truth in the bindings Errors map --- + // 100-109: signer storage / management + entry(100, "SignerNotFound", "SmartWallet", "The requested signer does not exist on this smart wallet."), + entry(101, "SignerAlreadyExists", "SmartWallet", "add_signer was called with a signer key that already exists."), + entry(102, "SignerExpired", "SmartWallet", "The signer's expiration timestamp is in the past."), + // 110-119: auth (__check_auth) + entry(110, "MissingContext", "SmartWallet", "No signer in the signatures map is permitted to authorize one of the requested auth contexts."), + entry(111, "SignatureKeyValueMismatch", "SmartWallet", "A signature's variant does not match the stored signer it claims to be for."), + // 120-129: WebAuthn (secp256r1) verification + entry(120, "ClientDataJsonTooLarge", "SmartWallet", "clientDataJSON exceeds the 1024-byte parse buffer."), + entry(121, "ClientDataJsonParseError", "SmartWallet", "clientDataJSON is not parseable JSON (or is missing required fields)."), + entry(122, "ClientDataJsonChallengeIncorrect", "SmartWallet", "The clientDataJSON challenge does not match the signature payload; this binding MUST NOT be weakened."), + entry(123, "InvalidWebAuthnType", "SmartWallet", 'clientDataJSON `type` is not "webauthn.get".'), + entry(124, "InvalidAuthenticatorData", "SmartWallet", "authenticatorData is shorter than the WebAuthn minimum of 37 bytes."), + entry(125, "UserPresenceRequired", "SmartWallet", "The authenticator did not set the User Present (UP) flag."), + + // --- Legacy (pre-1.0) 1-9 — kept so errors from legacy deployed wallets decode --- + entry(1, "NotFound", "SmartWalletLegacy", "[legacy] The specified signer was not found."), + entry(2, "AlreadyExists", "SmartWalletLegacy", "[legacy] The signer already exists on this wallet."), + entry(3, "MissingContext", "SmartWalletLegacy", "[legacy] The signature did not cover a required authorization context."), + entry(4, "SignerExpired", "SmartWalletLegacy", "[legacy] The signer has expired."), + entry(5, "FailedSignerLimits", "SmartWalletLegacy", "[legacy] The signer is not permitted to authorize this context by its limits."), + entry(6, "FailedPolicySignerLimits", "SmartWalletLegacy", "[legacy] A policy signer's limits rejected this context."), + entry(7, "SignatureKeyValueMismatch", "SmartWalletLegacy", "[legacy] A signature entry's key and value did not match."), + entry(8, "ClientDataJsonChallengeIncorrect", "SmartWalletLegacy", "[legacy] The WebAuthn clientDataJSON challenge did not match the signature payload."), + entry(9, "JsonParseError", "SmartWalletLegacy", "[legacy] The WebAuthn clientDataJSON could not be parsed."), ]) ); diff --git a/src/kit.ts b/src/kit.ts index 4b238c8..a3f388d 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -51,6 +51,7 @@ import { SubmissionManager, } from "./managers/index.js"; import { resolveDeployer } from "./kit/deploy-ops.js"; +import type { WalletTx } from "./kit/wallet-ops.js"; /** Configuration for a {@link PasskeyKit} client. */ export interface PasskeyKitConfig { @@ -391,7 +392,7 @@ export class PasskeyKit { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return this.signerManager.addSecp256r1(keyId, publicKey, limits, store, expiration); } updateSecp256r1( @@ -400,7 +401,7 @@ export class PasskeyKit { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return this.signerManager.updateSecp256r1(keyId, publicKey, limits, store, expiration); } addEd25519( @@ -408,7 +409,7 @@ export class PasskeyKit { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return this.signerManager.addEd25519(publicKey, limits, store, expiration); } updateEd25519( @@ -416,7 +417,7 @@ export class PasskeyKit { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return this.signerManager.updateEd25519(publicKey, limits, store, expiration); } addPolicy( @@ -424,7 +425,7 @@ export class PasskeyKit { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return this.signerManager.addPolicy(policy, limits, store, expiration); } updatePolicy( @@ -432,13 +433,18 @@ export class PasskeyKit { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return this.signerManager.updatePolicy(policy, limits, store, expiration); } - remove(signerKey: SignerKey): Promise> { + remove(signerKey: SignerKey): Promise { return this.signerManager.remove(signerKey); } + /** Build an `upgrade(new_wasm_hash)` transaction for the connected wallet. */ + upgrade(newWasmHash: Buffer | Uint8Array): Promise { + return this.signerManager.upgrade(newWasmHash); + } + /** Read a signer entry from the ledger (temporary before persistent). */ getSigner(signerKey: SignerKey) { return this.signerManager.getSigner(signerKey); diff --git a/src/kit/wallet-ops.ts b/src/kit/wallet-ops.ts index 5aeb30c..c68d349 100644 --- a/src/kit/wallet-ops.ts +++ b/src/kit/wallet-ops.ts @@ -15,10 +15,15 @@ import { Keypair } from "@stellar/stellar-sdk"; import { Durability, type Server } from "@stellar/stellar-sdk/rpc"; -import type { AssembledTransaction, Spec as ContractSpec } from "@stellar/stellar-sdk/contract"; +import type { + AssembledTransaction, + Result, + Spec as ContractSpec, +} from "@stellar/stellar-sdk/contract"; import { Client as PasskeyClient, type Signer as SDKSigner, + type SignerExpiration, type SignerKey as SDKSignerKey, type SignerLimits as SDKSignerLimits, type SignerVal, @@ -27,6 +32,18 @@ import base64url from "../base64url.js"; import { SignerStore, type SignerKey, type SignerLimits } from "../types.js"; import { signerKeyToScVal, SIGNER_VAL_UDT } from "./auth-payload.js"; +/** + * The result of a signer-write / upgrade transaction. The reworked contract's + * admin functions are typed `Result` (they return `Ok(())` or a typed + * contract error), so the AssembledTransaction is generic over that. + */ +export type WalletTx = AssembledTransaction>; + +/** Encode an optional expiration into the contract's `Option` timestamp. */ +function toSignerExpiration(expiration?: number): SignerExpiration { + return [expiration == null ? undefined : BigInt(expiration)]; +} + /** Which write the builder targets. */ type SignerFn = "add_signer" | "update_signer"; @@ -76,7 +93,7 @@ function buildSecp256r1Signer( values: [ keyIdBuffer, publicKeyBuffer, - [expiration], + toSignerExpiration(expiration), toContractSignerLimits(limits), { tag: store, values: undefined }, ], @@ -93,7 +110,7 @@ function buildEd25519Signer( tag: "Ed25519", values: [ Keypair.fromPublicKey(publicKey).rawPublicKey(), - [expiration], + toSignerExpiration(expiration), toContractSignerLimits(limits), { tag: store, values: undefined }, ], @@ -110,7 +127,7 @@ function buildPolicySigner( tag: "Policy", values: [ policy, - [expiration], + toSignerExpiration(expiration), toContractSignerLimits(limits), { tag: store, values: undefined }, ], @@ -131,7 +148,7 @@ export function buildSecp256r1SignerTx( limits: SignerLimits, store: SignerStore, expiration?: number -): Promise> { +): Promise { return deps.wallet[fn]( { signer: buildSecp256r1Signer(keyId, publicKey, limits, store, expiration) }, { timeoutInSeconds: deps.timeoutInSeconds } @@ -145,7 +162,7 @@ export function buildEd25519SignerTx( limits: SignerLimits, store: SignerStore, expiration?: number -): Promise> { +): Promise { return deps.wallet[fn]( { signer: buildEd25519Signer(publicKey, limits, store, expiration) }, { timeoutInSeconds: deps.timeoutInSeconds } @@ -159,7 +176,7 @@ export function buildPolicySignerTx( limits: SignerLimits, store: SignerStore, expiration?: number -): Promise> { +): Promise { return deps.wallet[fn]( { signer: buildPolicySigner(policy, limits, store, expiration) }, { timeoutInSeconds: deps.timeoutInSeconds } @@ -169,13 +186,24 @@ export function buildPolicySignerTx( export function buildRemoveSignerTx( deps: WalletWriteDeps, signerKey: SignerKey -): Promise> { +): Promise { return deps.wallet.remove_signer( { signer_key: toContractSignerKey(signerKey) }, { timeoutInSeconds: deps.timeoutInSeconds } ); } +/** Build an `upgrade(new_wasm_hash)` transaction (renamed from update_contract_code). */ +export function buildUpgradeTx( + deps: WalletWriteDeps, + newWasmHash: Buffer | Uint8Array +): Promise { + return deps.wallet.upgrade( + { new_wasm_hash: Buffer.from(newWasmHash) }, + { timeoutInSeconds: deps.timeoutInSeconds } + ); +} + /** * Read a signer entry from the ledger, or `null` if absent. * diff --git a/src/managers/signer-manager.test.ts b/src/managers/signer-manager.test.ts index 6863224..2d018a6 100644 --- a/src/managers/signer-manager.test.ts +++ b/src/managers/signer-manager.test.ts @@ -72,7 +72,7 @@ describe("SignerManager signer writes", () => { const [{ signer }] = wallet.add_signer.mock.calls[0]!; expect(signer.tag).toBe("Secp256r1"); - expect(signer.values[2]).toEqual([777]); // SignerExpiration + expect(signer.values[2]).toEqual([777n]); // SignerExpiration (Option) expect(signer.values[3]).toEqual([undefined]); // unlimited SignerLimits expect(signer.values[4]).toEqual({ tag: "Temporary", values: undefined }); }); diff --git a/src/managers/signer-manager.ts b/src/managers/signer-manager.ts index b76fbdf..1a41d16 100644 --- a/src/managers/signer-manager.ts +++ b/src/managers/signer-manager.ts @@ -35,7 +35,9 @@ import { buildPolicySignerTx, buildRemoveSignerTx, buildSecp256r1SignerTx, + buildUpgradeTx, getSigner as getSignerOp, + type WalletTx, } from "../kit/wallet-ops.js"; /** Extract the contract `Spec` from a generated client (not in the public type). */ @@ -113,7 +115,7 @@ export class SignerManager { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return buildSecp256r1SignerTx(this.writeDeps(), "add_signer", keyId, publicKey, limits, store, expiration); } @@ -123,7 +125,7 @@ export class SignerManager { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return buildSecp256r1SignerTx(this.writeDeps(), "update_signer", keyId, publicKey, limits, store, expiration); } @@ -132,7 +134,7 @@ export class SignerManager { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return buildEd25519SignerTx(this.writeDeps(), "add_signer", publicKey, limits, store, expiration); } @@ -141,7 +143,7 @@ export class SignerManager { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return buildEd25519SignerTx(this.writeDeps(), "update_signer", publicKey, limits, store, expiration); } @@ -150,7 +152,7 @@ export class SignerManager { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return buildPolicySignerTx(this.writeDeps(), "add_signer", policy, limits, store, expiration); } @@ -159,14 +161,19 @@ export class SignerManager { limits: SignerLimits, store: SignerStore, expiration?: number - ): Promise> { + ): Promise { return buildPolicySignerTx(this.writeDeps(), "update_signer", policy, limits, store, expiration); } - remove(signerKey: SignerKey): Promise> { + remove(signerKey: SignerKey): Promise { return buildRemoveSignerTx(this.writeDeps(), signerKey); } + /** Build an `upgrade(new_wasm_hash)` transaction. */ + upgrade(newWasmHash: Buffer | Uint8Array): Promise { + return buildUpgradeTx(this.writeDeps(), newWasmHash); + } + // -- Reads ------------------------------------------------------------------- /** Read a signer entry from the ledger (temporary before persistent). */ From c9cc70d7d4c9bec2bb57ea28cb7d726e7ab707eb Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 22:19:45 -0400 Subject: [PATCH 13/30] =?UTF-8?q?D1=20demo:=20Svelte=205=20rebuild=20?= =?UTF-8?q?=E2=80=94=20componentized,=20zero-secret,=20full=20feature=20co?= =?UTF-8?q?verage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ground-up rebuild of demo/ on Svelte 5 + Vite 6 against the frozen B1-B3 SDK client API. Kills the 427-line App.svelte and the secrets-in-bundle pattern. - Zero secrets: purge the committed Stellar seed + relayer key / Mercury JWT VITE_ inlining. Submission routes through a relayer-proxy worker ({func,auth}|{xdr}); discovery through an indexer proxy with a Mercury <-> Stellar-Indexer toggle. passkey-kit/server is never imported; the bundle carries no relayer/Channels client (verified via grep). - Storage via passkey-kit/storage LocalStorageAdapter (not hand-rolled localStorage). - Full feature coverage: passkey create / reconnect, add / update / remove for all three signer kinds, Persistent + Temporary, SignerLimits builder (unlimited / none / restricted), admin rotation + active-signer selection, multisig + per-signer transfers, non-native SAC token picker, typed-error surfacing (PasskeyKitError / ContractError with decoded contract codes). - Componentized (Header / Wallet / Transfer / Signers / Discovery / ActivityLog + reusable LimitsBuilder / SignerRow); Svelte 5 runes store + an actions layer so components stay thin. - Drop vite-plugin-filter-replace + the VITE_mercuryKey dead type; new public-only .env.example (rpc, passphrase, wasm hash pinned from the deployments manifest, native SAC, proxy URLs); fix index.html title + favicon. - data-testid selectors throughout for the D2 e2e harness. pnpm build:demo green (svelte-check 0 errors / 0 warnings + vite build). Refs #602, #597 s1.7, #600 s6; browser<->worker wire shape coordinated on todo 954. Co-Authored-By: Claude Opus 4.8 --- demo/.env.example | 40 +- demo/README.md | 77 ++- demo/index.html | 8 +- demo/package.json | 18 +- demo/pnpm-lock.yaml | 592 ++++++++++-------- demo/public/favicon.svg | 9 + demo/public/vite.svg | 1 - demo/src/App.svelte | 447 +------------ demo/src/app.css | 320 +++++++++- demo/src/lib/actions.ts | 564 +++++++++++++++++ demo/src/lib/common.ts | 49 -- demo/src/lib/components/ActivityLog.svelte | 38 ++ demo/src/lib/components/AddSignerForm.svelte | 89 +++ demo/src/lib/components/DiscoveryPanel.svelte | 73 +++ demo/src/lib/components/Header.svelte | 31 + demo/src/lib/components/LimitsBuilder.svelte | 79 +++ demo/src/lib/components/SignerRow.svelte | 100 +++ demo/src/lib/components/SignersPanel.svelte | 35 ++ demo/src/lib/components/TransferPanel.svelte | 113 ++++ demo/src/lib/components/WalletPanel.svelte | 69 ++ demo/src/lib/config.ts | 127 ++++ demo/src/lib/format.ts | 56 ++ demo/src/lib/indexer-proxy.ts | 127 ++++ demo/src/lib/relayer-proxy.ts | 148 +++++ demo/src/lib/signers.ts | 96 +++ demo/src/lib/state.svelte.ts | 90 +++ demo/src/lib/submit.ts | 95 +++ demo/src/main.ts | 20 +- demo/src/vite-env.d.ts | 49 +- demo/tsconfig.json | 7 +- demo/vite.config.ts | 25 +- 31 files changed, 2780 insertions(+), 812 deletions(-) create mode 100644 demo/public/favicon.svg delete mode 100644 demo/public/vite.svg create mode 100644 demo/src/lib/actions.ts delete mode 100644 demo/src/lib/common.ts create mode 100644 demo/src/lib/components/ActivityLog.svelte create mode 100644 demo/src/lib/components/AddSignerForm.svelte create mode 100644 demo/src/lib/components/DiscoveryPanel.svelte create mode 100644 demo/src/lib/components/Header.svelte create mode 100644 demo/src/lib/components/LimitsBuilder.svelte create mode 100644 demo/src/lib/components/SignerRow.svelte create mode 100644 demo/src/lib/components/SignersPanel.svelte create mode 100644 demo/src/lib/components/TransferPanel.svelte create mode 100644 demo/src/lib/components/WalletPanel.svelte create mode 100644 demo/src/lib/config.ts create mode 100644 demo/src/lib/format.ts create mode 100644 demo/src/lib/indexer-proxy.ts create mode 100644 demo/src/lib/relayer-proxy.ts create mode 100644 demo/src/lib/signers.ts create mode 100644 demo/src/lib/state.svelte.ts create mode 100644 demo/src/lib/submit.ts diff --git a/demo/.env.example b/demo/.env.example index 3234155..6819792 100644 --- a/demo/.env.example +++ b/demo/.env.example @@ -1,10 +1,32 @@ -VITE_walletWasmHash="ecd990f0b45ca6817149b6175f79b32efb442f35731985a084131e8265c4cd90" -VITE_nativeContractId="CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" -VITE_networkPassphrase="Test SDF Network ; September 2015" +# Passkey Kit demo — PUBLIC configuration only. +# +# Copy to `.env.local` and fill in. There are NO secrets here: the relayer API +# key and the Mercury / Stellar Indexer JWTs live only in the server-side proxy +# worker (see repo `relayer-proxy/`). The demo talks to that worker over the +# *Proxy URLs below and never sees a token. Never add a VITE_-prefixed secret. + +# --- Network ----------------------------------------------------------------- VITE_rpcUrl="https://soroban-testnet.stellar.org" -VITE_relayerUrl="https://channels.openzeppelin.com/testnet" -VITE_relayerApiKey="???" -VITE_mercuryProjectName="smart-wallets-next-dima" -VITE_mercuryUrl="https://api.mercurydata.app" -VITE_mercuryJwt="???" -# or VITE_mercuryKey="???" +VITE_networkPassphrase="Test SDF Network ; September 2015" + +# Smart-wallet WASM hash — pinned from docs/deployments-testnet-2026-07-11.md +# (canonical source). Re-pinned after the contract audit gate + bindings regen. +VITE_walletWasmHash="9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe" + +# --- Tokens (SAC / SEP-41) --------------------------------------------------- +# Native XLM SAC on testnet. +VITE_nativeContractId="CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" +# Optional extra tokens for the non-native SAC demo: comma-separated label:C… +# VITE_extraTokenIds="USDC:C..." + +# --- Policy signer (optional) ------------------------------------------------ +# Deployed instance of the v1 sample-policy contract. Leave blank to disable the +# policy-signer controls. Deploy from the sample-policy WASM in the manifest. +# VITE_samplePolicyId="C..." + +# --- Server-side proxies (no secrets in the browser) ------------------------- +# Relayer-proxy worker (submission). Unset => submission disabled in the UI. +# VITE_relayerProxyUrl="https://passkey-kit-relayer-proxy..workers.dev" +# Indexer-proxy (signer discovery, Mercury + Stellar Indexer). Unset => discovery +# shows "pending backend". +# VITE_indexerProxyUrl="https://passkey-kit-relayer-proxy..workers.dev" diff --git a/demo/README.md b/demo/README.md index e6cd94f..7c1ad03 100644 --- a/demo/README.md +++ b/demo/README.md @@ -1,47 +1,58 @@ -# Svelte + TS + Vite +# Passkey Kit demo -This template should help get you started developing with Svelte and TypeScript in Vite. +A Svelte 5 + Vite app that exercises the full `passkey-kit` client API against +Stellar testnet smart wallets: passkey create / reconnect, add / update / remove +signers (secp256r1 passkeys, Ed25519 keys, policy signers), Persistent and +Temporary storage, per-signer `SignerLimits`, admin rotation, multisig and +per-signer transfers, non-native SAC support, relayer-sponsored submission, and +signer discovery through both indexer backends. -## Recommended IDE Setup +## Zero secrets in the bundle -[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). +Nothing here holds a secret. The client builds and **signs** transactions, then: -## Need an official Svelte framework? +- **submits** them through a server-side **relayer-proxy worker** + (`VITE_relayerProxyUrl`) that holds the relayer key (keyless per-IP minting), + and +- **discovers** signers through a server-side **indexer proxy** + (`VITE_indexerProxyUrl`) that holds the Mercury / Stellar Indexer credentials. -Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. +`passkey-kit/server` (which carries those secrets) is never imported. Passkey → +wallet records are persisted with the SDK's `LocalStorageAdapter`, not hand-rolled +`localStorage`. -## Technical considerations +## Configure -**Why use this over SvelteKit?** - -- It brings its own routing solution which might not be preferable for some users. -- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. - -This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. - -Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. - -**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** - -Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information. - -**Why include `.vscode/extensions.json`?** +```sh +cp .env.example .env.local +# fill in the public values; leave the *ProxyUrl vars unset to run the core +# flows without a live worker (submission/discovery then show "pending backend"). +``` -Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. +Every `.env` value is public — see `.env.example`. Set `VITE_samplePolicyId` to a +deployed v1 `sample-policy` instance to enable the policy-signer controls, and +`VITE_extraTokenIds` (`label:C…,…`) to add non-native SAC tokens to the picker. -**Why enable `allowJs` in the TS template?** +## Run -While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant. +```sh +pnpm --ignore-workspace install +pnpm dev # http://localhost:5173 +pnpm build # svelte-check + production build +``` -**Why is HMR not preserving my local component state?** +The demo consumes the **compiled** `passkey-kit` from the repo root via +`link:..`, so build the SDK there first (`pnpm build` in the repo root) if `dist/` +is missing. -HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr). +## Architecture -If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. +- `src/lib/config.ts` — public config + singletons (`PasskeyKit`, `SACClient`, + storage adapter, relayer/indexer proxy clients). +- `src/lib/actions.ts` — every flow that touches the SDK (thin components call in). +- `src/lib/state.svelte.ts` — the reactive store (Svelte 5 runes). +- `src/lib/{relayer-proxy,indexer-proxy,submit}.ts` — the browser↔worker seams. +- `src/lib/components/` — one panel per concern. -```ts -// store.ts -// An extremely simple external store -import { writable } from 'svelte/store' -export default writable(0) -``` +Live end-to-end verification (real `C…` ids, tx hashes, both indexer backends) +runs through the e2e harness in the repo `scripts/` (todo 956). diff --git a/demo/index.html b/demo/index.html index b6c5f0a..ebdb5a3 100644 --- a/demo/index.html +++ b/demo/index.html @@ -2,9 +2,13 @@ - + - Vite + Svelte + TS + + Passkey Kit — Stellar smart wallets
diff --git a/demo/package.json b/demo/package.json index a15fb4e..8aabc4e 100644 --- a/demo/package.json +++ b/demo/package.json @@ -1,26 +1,26 @@ { + "name": "passkey-kit-demo", + "private": true, "type": "module", "scripts": { "dev": "vite", - "dev:prod": "vite --mode production", "start": "vite", - "build": "vite build", + "build": "svelte-check --tsconfig ./tsconfig.json && vite build", "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.json" }, "devDependencies": { - "@stellar/stellar-sdk": "16.0.0-rc.1", - "@sveltejs/vite-plugin-svelte": "^3.1.2", - "@tsconfig/svelte": "^5.0.5", + "@stellar/stellar-sdk": "16.0.1", + "@sveltejs/vite-plugin-svelte": "^5.1.1", + "@tsconfig/svelte": "^5.0.8", "base64url": "^3.0.1", "buffer": "^6.0.3", "passkey-kit": "link:..", - "svelte": "^4.2.20", - "svelte-check": "^4.3.1", + "svelte": "^5.56.4", + "svelte-check": "^4.7.2", "tslib": "^2.8.1", "typescript": "^5.9.2", - "vite": "^5.4.19", - "vite-plugin-filter-replace": "^0.1.14" + "vite": "^6.3.6" }, "pnpm": { "overrides": { diff --git a/demo/pnpm-lock.yaml b/demo/pnpm-lock.yaml index bf8b859..5fa061c 100644 --- a/demo/pnpm-lock.yaml +++ b/demo/pnpm-lock.yaml @@ -5,21 +5,21 @@ settings: excludeLinksFromLockfile: false overrides: - '@stellar/stellar-sdk': 16.0.0-rc.1 + '@stellar/stellar-sdk': 16.0.1 importers: .: devDependencies: '@stellar/stellar-sdk': - specifier: 16.0.0-rc.1 - version: 16.0.0-rc.1 + specifier: 16.0.1 + version: 16.0.1 '@sveltejs/vite-plugin-svelte': - specifier: ^3.1.2 - version: 3.1.2(svelte@4.2.20)(vite@5.4.21) + specifier: ^5.1.1 + version: 5.1.1(svelte@5.56.4)(vite@6.4.3) '@tsconfig/svelte': - specifier: ^5.0.5 - version: 5.0.5 + specifier: ^5.0.8 + version: 5.0.8 base64url: specifier: ^3.0.1 version: 3.0.1 @@ -30,11 +30,11 @@ importers: specifier: link:.. version: link:.. svelte: - specifier: ^4.2.20 - version: 4.2.20 + specifier: ^5.56.4 + version: 5.56.4 svelte-check: - specifier: ^4.3.1 - version: 4.3.1(svelte@4.2.20)(typescript@5.9.2) + specifier: ^4.7.2 + version: 4.7.2(picomatch@4.0.5)(svelte@5.56.4)(typescript@5.9.2) tslib: specifier: ^2.8.1 version: 2.8.1 @@ -42,153 +42,164 @@ importers: specifier: ^5.9.2 version: 5.9.2 vite: - specifier: ^5.4.19 - version: 5.4.21 - vite-plugin-filter-replace: - specifier: ^0.1.14 - version: 0.1.14 + specifier: ^6.3.6 + version: 6.4.3 packages: - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -196,6 +207,9 @@ packages: resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -326,32 +340,44 @@ packages: resolution: {integrity: sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==} engines: {node: '>=20.0.0', pnpm: '>=9.0.0'} - '@stellar/stellar-sdk@16.0.0-rc.1': - resolution: {integrity: sha512-mkOQUsFgepFIxRFJLgjkLAkYQDBdNKgLpMM5yIWQcCUoXavb0gO1Dlw/hPqVg2KBKUKaXs2ljVLuB4x6bSWR0g==} + '@stellar/stellar-sdk@16.0.1': + resolution: {integrity: sha512-bxKohaiyKVqoudRhbOOHeHhHIaeYV5Zab4rCjxhP4Ty1h1ozTLBOv8lWFnZz9ilBzXG8Bb7usQI3rlEcfvUynA==} engines: {node: '>=22.0.0'} hasBin: true - '@sveltejs/vite-plugin-svelte-inspector@2.1.0': - resolution: {integrity: sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==} - engines: {node: ^18.0.0 || >=20} + '@sveltejs/acorn-typescript@1.0.11': + resolution: {integrity: sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==} peerDependencies: - '@sveltejs/vite-plugin-svelte': ^3.0.0 - svelte: ^4.0.0 || ^5.0.0-next.0 - vite: ^5.0.0 + acorn: ^8.9.0 - '@sveltejs/vite-plugin-svelte@3.1.2': - resolution: {integrity: sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==} - engines: {node: ^18.0.0 || >=20} + '@sveltejs/load-config@0.2.0': + resolution: {integrity: sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} peerDependencies: - svelte: ^4.0.0 || ^5.0.0-next.0 - vite: ^5.0.0 + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 - '@tsconfig/svelte@5.0.5': - resolution: {integrity: sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ==} + '@tsconfig/svelte@5.0.8': + resolution: {integrity: sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==} '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + acorn@8.14.1: resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} engines: {node: '>=0.4.0'} @@ -361,8 +387,8 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} asynckit@0.4.0: @@ -400,8 +426,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - code-red@1.0.4: - resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} @@ -411,10 +438,6 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} - css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -424,6 +447,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -432,6 +464,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -452,13 +487,21 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} hasBin: true - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.13: + resolution: {integrity: sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} @@ -476,6 +519,15 @@ packages: picomatch: optional: true + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + feaxios@0.0.23: resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} @@ -552,9 +604,6 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -575,12 +624,13 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - periscopic@3.1.0: - resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + postcss@8.5.3: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} @@ -610,23 +660,21 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - svelte-check@4.3.1: - resolution: {integrity: sha512-lkh8gff5gpHLjxIV+IaApMxQhTGnir2pNUAqcNgeKkvK5bT/30Ey/nzBxNLDlkztCH4dP7PixkMt9SWEKFPBWg==} + svelte-check@4.7.2: + resolution: {integrity: sha512-GoS4XJdGswlq0rIT1vtFLzJY1bvHtY37McY9H9Gkm1Ggw/ICdZYn8J/Z8Yi0BEL0i3R4+jtaWVePjyppMlij/A==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' - svelte-hmr@0.16.0: - resolution: {integrity: sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==} - engines: {node: ^12.20 || ^14.13.1 || >= 16} - peerDependencies: - svelte: ^3.19.0 || ^4.0.0 + svelte@5.56.4: + resolution: {integrity: sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==} + engines: {node: '>=18'} - svelte@4.2.20: - resolution: {integrity: sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==} - engines: {node: '>=16'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -640,25 +688,27 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} - vite-plugin-filter-replace@0.1.14: - resolution: {integrity: sha512-3onEAYgjJQTCJN+/f8+1vOUH3X/tRmXsfxmgH3QtpReGKi+xtP1jMpAEnYsNqw9mI2shZmxFm3LS+n52XczwYQ==} - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' less: '*' lightningcss: ^1.21.0 sass: '*' sass-embedded: '*' stylus: '*' sugarss: '*' - terser: ^5.4.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -673,89 +723,100 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true - vitefu@0.2.5: - resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: vite: optional: true + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + snapshots: - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/win32-x64@0.25.12': optional: true '@jridgewell/gen-mapping@0.3.8': @@ -764,6 +825,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/set-array@1.2.1': {} @@ -838,7 +904,7 @@ snapshots: '@stellar/js-xdr@4.0.0': {} - '@stellar/stellar-sdk@16.0.0-rc.1': + '@stellar/stellar-sdk@16.0.1': dependencies: '@noble/ed25519': 3.1.0 '@noble/hashes': 2.2.0 @@ -856,33 +922,40 @@ snapshots: - debug - supports-color - '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.21))(svelte@4.2.20)(vite@5.4.21)': + '@sveltejs/acorn-typescript@1.0.11(acorn@8.14.1)': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.20)(vite@5.4.21) - debug: 4.4.0 - svelte: 4.2.20 - vite: 5.4.21 + acorn: 8.14.1 + + '@sveltejs/load-config@0.2.0': {} + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.4)(vite@6.4.3))(svelte@5.56.4)(vite@6.4.3)': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.56.4)(vite@6.4.3) + debug: 4.4.3 + svelte: 5.56.4 + vite: 6.4.3 transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.21)': + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.4)(vite@6.4.3)': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.21))(svelte@4.2.20)(vite@5.4.21) - debug: 4.4.0 + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.4)(vite@6.4.3))(svelte@5.56.4)(vite@6.4.3) + debug: 4.4.3 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.17 - svelte: 4.2.20 - svelte-hmr: 0.16.0(svelte@4.2.20) - vite: 5.4.21 - vitefu: 0.2.5(vite@5.4.21) + svelte: 5.56.4 + vite: 6.4.3 + vitefu: 1.1.3(vite@6.4.3) transitivePeerDependencies: - supports-color - '@tsconfig/svelte@5.0.5': {} + '@tsconfig/svelte@5.0.8': {} '@types/estree@1.0.6': {} + '@types/trusted-types@2.0.7': {} + acorn@8.14.1: {} agent-base@6.0.2: @@ -891,7 +964,7 @@ snapshots: transitivePeerDependencies: - supports-color - aria-query@5.3.2: {} + aria-query@5.3.1: {} asynckit@0.4.0: {} @@ -929,13 +1002,7 @@ snapshots: dependencies: readdirp: 4.1.2 - code-red@1.0.4: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - '@types/estree': 1.0.6 - acorn: 8.14.1 - estree-walker: 3.0.3 - periscopic: 3.1.0 + clsx@2.1.1: {} combined-stream@1.0.8: dependencies: @@ -943,12 +1010,11 @@ snapshots: commander@14.0.3: {} - css-tree@2.3.1: + debug@4.4.0: dependencies: - mdn-data: 2.0.30 - source-map-js: 1.2.1 + ms: 2.1.3 - debug@4.4.0: + debug@4.4.3: dependencies: ms: 2.1.3 @@ -956,6 +1022,8 @@ snapshots: delayed-stream@1.0.0: {} + devalue@5.8.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -977,35 +1045,40 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - esbuild@0.21.5: + esbuild@0.25.12: optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - estree-walker@3.0.3: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esm-env@1.2.2: {} + + esrap@2.2.13: dependencies: - '@types/estree': 1.0.6 + '@jridgewell/sourcemap-codec': 1.5.0 eventsource-parser@3.1.0: {} @@ -1013,7 +1086,13 @@ snapshots: dependencies: eventsource-parser: 3.1.0 - fdir@6.4.3: {} + fdir@6.4.3(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 feaxios@0.0.23: dependencies: @@ -1089,8 +1168,6 @@ snapshots: math-intrinsics@1.1.0: {} - mdn-data@2.0.30: {} - mime-db@1.52.0: {} mime-types@2.1.35: @@ -1103,14 +1180,10 @@ snapshots: nanoid@3.3.10: {} - periscopic@3.1.0: - dependencies: - '@types/estree': 1.0.6 - estree-walker: 3.0.3 - is-reference: 3.0.3 - picocolors@1.1.1: {} + picomatch@4.0.5: {} + postcss@8.5.3: dependencies: nanoid: 3.3.10 @@ -1154,38 +1227,44 @@ snapshots: source-map-js@1.2.1: {} - svelte-check@4.3.1(svelte@4.2.20)(typescript@5.9.2): + svelte-check@4.7.2(picomatch@4.0.5)(svelte@5.56.4)(typescript@5.9.2): dependencies: '@jridgewell/trace-mapping': 0.3.25 + '@sveltejs/load-config': 0.2.0 chokidar: 4.0.3 - fdir: 6.4.3 + fdir: 6.4.3(picomatch@4.0.5) picocolors: 1.1.1 sade: 1.8.1 - svelte: 4.2.20 + svelte: 5.56.4 typescript: 5.9.2 transitivePeerDependencies: - picomatch - svelte-hmr@0.16.0(svelte@4.2.20): - dependencies: - svelte: 4.2.20 - - svelte@4.2.20: + svelte@5.56.4: dependencies: - '@ampproject/remapping': 2.3.0 + '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 + '@sveltejs/acorn-typescript': 1.0.11(acorn@8.14.1) '@types/estree': 1.0.6 + '@types/trusted-types': 2.0.7 acorn: 8.14.1 - aria-query: 5.3.2 + aria-query: 5.3.1 axobject-query: 4.1.0 - code-red: 1.0.4 - css-tree: 2.3.1 - estree-walker: 3.0.3 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.13 is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.17 - periscopic: 3.1.0 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tslib@2.8.1: {} @@ -1193,18 +1272,19 @@ snapshots: uint8array-extras@1.5.0: {} - vite-plugin-filter-replace@0.1.14: + vite@6.4.3: dependencies: - magic-string: 0.30.17 - - vite@5.4.21: - dependencies: - esbuild: 0.21.5 + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 postcss: 8.5.3 rollup: 4.36.0 + tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 - vitefu@0.2.5(vite@5.4.21): + vitefu@1.1.3(vite@6.4.3): optionalDependencies: - vite: 5.4.21 + vite: 6.4.3 + + zimmerframe@1.1.4: {} diff --git a/demo/public/favicon.svg b/demo/public/favicon.svg new file mode 100644 index 0000000..82859f5 --- /dev/null +++ b/demo/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/demo/public/vite.svg b/demo/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/demo/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/demo/src/App.svelte b/demo/src/App.svelte index 87564d7..6d15bb4 100644 --- a/demo/src/App.svelte +++ b/demo/src/App.svelte @@ -1,427 +1,32 @@ -
- - - - - {#if contractId} -

{contractId}

- - {#if balance} -

{parseFloat((Number(balance) / 10_000_000).toFixed(7))} XLM

- {/if} - - - -
- - -
- - -
- - -
-
    -
  • - -
  • - -
  • - -
  • -
-
- {/if} - -
    - {#each signers as { kind, key, val, expiration, limits, evicted }} -
  • - - - {key} - - - - - - +
    +
    - -
  • - {/each} -
+
+ + {#if connected} + + + + {/if} +
- +
diff --git a/demo/src/app.css b/demo/src/app.css index bd6a1f6..26563a4 100644 --- a/demo/src/app.css +++ b/demo/src/app.css @@ -1,14 +1,316 @@ :root { + --bg: #0b1120; + --panel: #111a2e; + --panel-2: #16223c; + --border: #26324d; + --text: #e6edf7; + --muted: #93a1bd; + --accent: #38bdf8; + --accent-strong: #0ea5e9; + --good: #34d399; + --bad: #f87171; + --warn: #fbbf24; + --radius: 12px; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; - font-weight: 400; + color: var(--text); + background-color: var(--bg); + color-scheme: dark; + -webkit-font-smoothing: antialiased; +} - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; +@media (prefers-color-scheme: light) { + :root { + --bg: #f4f7fc; + --panel: #ffffff; + --panel-2: #eef2f9; + --border: #d7deea; + --text: #0f1b33; + --muted: #55617a; + --accent: #0284c7; + --accent-strong: #0369a1; + --good: #059669; + --bad: #dc2626; + --warn: #b45309; + color-scheme: light; + } +} - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} \ No newline at end of file +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +h1, +h2, +h3 { + margin: 0; + line-height: 1.2; +} + +a { + color: var(--accent); +} + +code, +.mono { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + font-size: 0.85em; +} + +.app { + max-width: 1120px; + margin: 0 auto; + padding: 24px 20px 80px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 16px; + align-items: start; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; +} + +.panel h2 { + font-size: 15px; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 14px; +} + +.row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.col { + display: flex; + flex-direction: column; + gap: 10px; +} + +.spread { + justify-content: space-between; +} + +button { + font: inherit; + cursor: pointer; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + border-radius: 8px; + padding: 8px 12px; + transition: border-color 0.15s, background 0.15s; +} + +button:hover:not(:disabled) { + border-color: var(--accent); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.primary { + background: var(--accent-strong); + border-color: var(--accent-strong); + color: #04121f; + font-weight: 600; +} + +button.ghost { + background: transparent; +} + +button.danger { + color: var(--bad); +} + +button.sm { + padding: 4px 8px; + font-size: 0.85em; +} + +input, +select { + font: inherit; + color: var(--text); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + min-width: 0; +} + +input:focus, +select:focus { + outline: none; + border-color: var(--accent); +} + +label, +.label { + font-size: 0.85em; + color: var(--muted); +} + +.label { + display: block; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.field input, +.field select { + width: 100%; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--muted); +} + +.badge.admin { + color: var(--warn); + border-color: var(--warn); +} +.badge.self { + color: var(--accent); + border-color: var(--accent); +} +.badge.good { + color: var(--good); + border-color: var(--good); +} +.badge.bad { + color: var(--bad); + border-color: var(--bad); +} + +.muted { + color: var(--muted); +} + +.hint { + font-size: 0.82em; + color: var(--muted); +} + +.divider { + height: 1px; + background: var(--border); + margin: 12px 0; + border: 0; +} + +.big-id { + font-family: ui-monospace, monospace; + font-size: 0.9em; + word-break: break-all; + color: var(--text); +} + +.balance { + font-size: 1.8rem; + font-weight: 700; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.signer { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + background: var(--panel-2); +} + +.log { + max-height: 320px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.86em; +} + +.log-entry { + padding: 6px 10px; + border-radius: 8px; + border-left: 3px solid var(--border); + background: var(--panel-2); +} +.log-entry.success { + border-left-color: var(--good); +} +.log-entry.error { + border-left-color: var(--bad); +} +.log-entry.info { + border-left-color: var(--accent); +} +.log-detail { + color: var(--muted); + font-size: 0.92em; +} + +.pending { + border: 1px dashed var(--warn); + color: var(--warn); + border-radius: 8px; + padding: 8px 10px; + font-size: 0.85em; +} + +.seg { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.seg button { + border: 0; + border-radius: 0; + background: transparent; +} +.seg button.active { + background: var(--accent-strong); + color: #04121f; + font-weight: 600; +} diff --git a/demo/src/lib/actions.ts b/demo/src/lib/actions.ts new file mode 100644 index 0000000..c12a38f --- /dev/null +++ b/demo/src/lib/actions.ts @@ -0,0 +1,564 @@ +/** + * Application flows: every user action that touches the SDK lives here, so + * components stay thin (markup + an `onclick` that calls an action). Each flow + * mutates the reactive {@link app} store and surfaces typed errors + real + * tx hashes through the activity log. + */ + +import { Buffer } from "buffer"; +import { Keypair } from "@stellar/stellar-sdk"; +import base64url from "base64url"; +import { + Ed25519Signer, + PasskeySigner, + PolicySigner, + SignerStore, + type Signer, + type TransactionResult, +} from "passkey-kit"; +import { + account, + config, + fundKeypair, + fundSigner, + indexer, + sac, + storage, +} from "./config"; +import { submit } from "./submit"; +import { app, isConnected, pushLog } from "./state.svelte"; +import { describeError } from "./format"; +import type { IndexerBackend } from "./indexer-proxy"; +import { + buildSignerLimits, + describeLimits, + toSignerKey, + UNLIMITED, + type LimitsSpec, + type LocalSigner, + type SignerKind, +} from "./signers"; + +/** The AssembledTransaction the kit's add/update/remove builders return. */ +type WalletWriteTx = Awaited>; + +// -- Small internals ---------------------------------------------------------- + +/** Run a flow with busy state + typed error surfacing. Returns undefined on error. */ +async function run(label: string, fn: () => Promise): Promise { + if (app.busy) { + pushLog("error", `Busy — "${app.busy}" is still running`); + return undefined; + } + app.busy = label; + try { + return await fn(); + } catch (err) { + const d = describeError(err); + pushLog("error", `${label} failed: ${d.message}`, { detail: errorDetail(d) }); + return undefined; + } finally { + app.busy = null; + } +} + +function errorDetail(d: ReturnType): string { + const parts = [d.name]; + if (d.code !== undefined) parts.push(`[${d.code}]`); + if (d.contract) parts.push(`· ${d.contract}`); + return parts.join(" "); +} + +/** Log a submission {@link TransactionResult}. Returns success. */ +function reportResult(label: string, res: TransactionResult): boolean { + if (res.success) { + pushLog("success", `${label} ✓`, { + hash: res.hash, + detail: res.ledger ? `ledger ${res.ledger}` : undefined, + }); + return true; + } + const d = describeError(res.error); + pushLog("error", `${label} failed: ${d.message}`, { + hash: res.hash, + detail: errorDetail(d), + }); + return false; +} + +/** The Signer object used to authorize wallet writes (the active admin passkey). */ +function adminSigner(): Signer { + return new PasskeySigner(app.activeKeyId ?? app.keyId); +} + +function upsertSigner(signer: LocalSigner): void { + const i = app.signers.findIndex( + (s) => s.kind === signer.kind && s.value === signer.value, + ); + if (i >= 0) app.signers[i] = signer; + else app.signers.push(signer); +} + +function removeFromRegistry(kind: SignerKind, value: string): void { + app.signers = app.signers.filter((s) => !(s.kind === kind && s.value === value)); +} + +function localToken(): ReturnType { + return sac.getSACClient(app.selectedToken || config.nativeContractId); +} + +// -- Boot --------------------------------------------------------------------- + +/** Refresh the list of passkeys held by the storage adapter. */ +export async function refreshKnown(): Promise { + app.knownPasskeys = await storage.getAll().catch(() => []); +} + +/** On load, offer the most recently-used stored passkey for a one-tap reconnect. */ +export async function boot(): Promise { + app.selectedToken = config.nativeContractId; + try { + const known = await storage.getAll(); + app.knownPasskeys = known; + if (known.length) { + const latest = [...known].sort( + (a, b) => (b.lastUsedAt ?? b.createdAt) - (a.lastUsedAt ?? a.createdAt), + )[0]!; + pushLog("info", `Found stored passkey ${latest.keyId.slice(0, 10)}… — reconnecting`); + await connect(latest.keyId); + } + } catch (err) { + pushLog("info", `No stored passkey (${describeError(err).message})`); + } finally { + app.ready = true; + } +} + +// -- Wallet lifecycle --------------------------------------------------------- + +export async function register(name: string): Promise { + await run("Create wallet", async () => { + const { keyIdBase64, contractId, signedTx, keyId } = await account.createWallet( + "Passkey Kit Demo", + name, + ); + const ok = reportResult("Deploy wallet", await submit(signedTx)); + if (!ok) throw new Error("Wallet deploy was not accepted by the relayer"); + + setConnected(keyIdBase64, contractId); + // The constructor signer is unlimited (admin). + const passkey = await storage.get(keyIdBase64); + upsertSigner({ + kind: "Secp256r1", + value: keyIdBase64, + store: SignerStore.Persistent, + limitsMode: "unlimited", + self: true, + label: name, + publicKey: passkey ? base64url(Buffer.from(passkey.publicKey)) : undefined, + }); + pushLog("success", `Wallet created`, { detail: `keyId ${keyIdBase64.slice(0, 10)}… · ${bytesLabel(keyId)}` }); + await refreshKnown(); + + await refreshBalance(); + await fundWallet(100); + }); +} + +export async function connect(keyId?: string): Promise { + await run("Connect wallet", async () => { + app.status = "connecting"; + const { keyIdBase64, contractId } = await account.connectWallet({ + keyId, + getContractId: (kid) => + indexer + .findWallets({ kind: "Secp256r1", value: kid }, app.discoverBackend) + .then((ids) => ids[0]) + .catch(() => undefined), + }); + + setConnected(keyIdBase64, contractId); + await storage.update(keyIdBase64, { lastUsedAt: Date.now() }).catch(() => {}); + const passkey = await storage.get(keyIdBase64); + upsertSigner({ + kind: "Secp256r1", + value: keyIdBase64, + store: SignerStore.Persistent, + limitsMode: "unlimited", + self: true, + label: passkey?.nickname ?? "This passkey", + publicKey: passkey ? base64url(Buffer.from(passkey.publicKey)) : undefined, + }); + pushLog("success", `Connected — ownership verified`, { + detail: `wallet ${contractId.slice(0, 8)}…`, + }); + await refreshKnown(); + await refreshBalance(); + }); +} + +function setConnected(keyId: string, contractId: string): void { + app.keyId = keyId; + app.activeKeyId = keyId; + app.contractId = contractId; + app.status = "connected"; +} + +export function disconnect(): void { + account.disconnect(); + app.status = "disconnected"; + app.keyId = undefined; + app.contractId = undefined; + app.activeKeyId = undefined; + app.signers = []; + app.balances = {}; + app.discovered = []; + pushLog("info", "Disconnected"); +} + +export async function forgetAll(): Promise { + await run("Forget stored passkeys", async () => { + await storage.clear(); + disconnect(); + app.knownPasskeys = []; + pushLog("info", "Cleared passkey storage"); + }); +} + +// -- Signer management -------------------------------------------------------- + +export interface AddSignerInput { + store: SignerStore; + limits: LimitsSpec; + expiration?: number; +} + +export async function addPasskeySigner(name: string, input: AddSignerInput): Promise { + if (!requireConnected()) return; + await run("Add passkey signer", async () => { + const created = await account.createKey("Passkey Kit Demo", name); + const limits = buildSignerLimits(input.limits); + const at = await account.addSecp256r1( + created.keyId, + created.publicKey, + limits, + input.store, + input.expiration, + ); + if (!(await signAndSubmit("Add passkey signer", at))) return; + + // Persist so it is reconnectable + has a retrievable public key. + await storage + .save({ + keyId: created.keyId, + publicKey: created.publicKey, + contractId: app.contractId!, + nickname: name, + createdAt: Date.now(), + }) + .catch(() => {}); + upsertSigner({ + kind: "Secp256r1", + value: created.keyId, + store: input.store, + limitsMode: input.limits.mode, + expiration: input.expiration, + label: name, + publicKey: base64url(Buffer.from(created.publicKey)), + }); + await refreshKnown(); + pushLog("info", `Passkey signer added · ${describeLimits(input.limits)}`); + }); +} + +export async function addEd25519Signer(input: AddSignerInput): Promise { + if (!requireConnected()) return; + const pub = ensureEd25519().publicKey(); + await run("Add Ed25519 signer", async () => { + const at = await account.addEd25519( + pub, + buildSignerLimits(input.limits), + input.store, + input.expiration, + ); + if (!(await signAndSubmit("Add Ed25519 signer", at))) return; + upsertSigner({ + kind: "Ed25519", + value: pub, + store: input.store, + limitsMode: input.limits.mode, + expiration: input.expiration, + label: "Ed25519 key", + }); + pushLog("info", `Ed25519 signer added · ${describeLimits(input.limits)}`); + }); +} + +export async function addPolicySigner(input: AddSignerInput): Promise { + if (!requireConnected()) return; + if (!config.samplePolicyId) { + pushLog("error", "No sample policy configured (set VITE_samplePolicyId)"); + return; + } + const policy = config.samplePolicyId; + await run("Add policy signer", async () => { + const at = await account.addPolicy( + policy, + buildSignerLimits(input.limits), + input.store, + input.expiration, + ); + if (!(await signAndSubmit("Add policy signer", at))) return; + upsertSigner({ + kind: "Policy", + value: policy, + store: input.store, + limitsMode: input.limits.mode, + expiration: input.expiration, + label: "Policy", + }); + pushLog("info", `Policy signer added · ${describeLimits(input.limits)}`); + }); +} + +export async function updateSigner( + signer: LocalSigner, + input: AddSignerInput, +): Promise { + if (!requireConnected()) return; + await run(`Update ${signer.kind} signer`, async () => { + const limits = buildSignerLimits(input.limits); + let at: WalletWriteTx; + switch (signer.kind) { + case "Secp256r1": { + const publicKey = await resolvePublicKey(signer); + if (!publicKey) throw new Error("Missing public key for this passkey (cannot update)"); + at = await account.updateSecp256r1(signer.value, publicKey, limits, input.store, input.expiration); + break; + } + case "Ed25519": + at = await account.updateEd25519(signer.value, limits, input.store, input.expiration); + break; + case "Policy": + at = await account.updatePolicy(signer.value, limits, input.store, input.expiration); + break; + } + if (!(await signAndSubmit(`Update ${signer.kind} signer`, at))) return; + upsertSigner({ + ...signer, + store: input.store, + limitsMode: input.limits.mode, + expiration: input.expiration, + }); + pushLog("info", `Signer updated · ${describeLimits(input.limits)}`); + }); +} + +export async function removeSigner(signer: LocalSigner): Promise { + if (!requireConnected()) return; + await run(`Remove ${signer.kind} signer`, async () => { + const at = await account.remove(toSignerKey(signer)); + if (!(await signAndSubmit(`Remove ${signer.kind} signer`, at))) return; + removeFromRegistry(signer.kind, signer.value); + if (signer.kind === "Secp256r1") { + await storage.delete(signer.value).catch(() => {}); + await refreshKnown(); + } + pushLog("info", `Signer removed`); + }); +} + +/** Admin rotation: add a fresh unlimited passkey and make it the active signer. */ +export async function rotateAdmin(name: string): Promise { + if (!requireConnected()) return; + await addPasskeySigner(name, { store: SignerStore.Persistent, limits: UNLIMITED }); + const added = app.signers.find((s) => s.kind === "Secp256r1" && s.label === name); + if (added) { + setActiveSigner(added.value); + pushLog("success", `Admin rotated — new active signer ${added.value.slice(0, 10)}…`); + } +} + +export function setActiveSigner(keyId: string): void { + app.activeKeyId = keyId; + pushLog("info", `Active signer set to ${keyId.slice(0, 10)}…`); +} + +// -- Transfers + balances ----------------------------------------------------- + +export type SignWith = "passkey" | "ed25519" | "policy" | "multisig"; + +export async function transfer(to: string, amount: bigint, signWith: SignWith): Promise { + if (!requireConnected()) return; + await doTransfer(`Transfer (${signWith})`, to, amount, signersFor(signWith)); +} + +/** Quick 1-token transfer back to the funding account, signed by one signer. */ +export async function quickTransferFrom(signer: LocalSigner): Promise { + if (!requireConnected()) return; + const to = (await fundKeypair).publicKey(); + await doTransfer(`Transfer via ${signer.kind}`, to, 10_000_000n, signersForLocal(signer)); +} + +async function doTransfer( + label: string, + to: string, + amount: bigint, + signers: Signer[], +): Promise { + await run(label, async () => { + const token = localToken(); + let at = await token.transfer({ from: app.contractId!, to, amount }); + for (const signer of signers) { + at = await account.sign(at, signer); + } + if (!reportResult(label, await submit(at))) return; + await refreshBalance(); + }); +} + +/** The signer object(s) for a specific registry row. */ +function signersForLocal(signer: LocalSigner): Signer[] { + switch (signer.kind) { + case "Secp256r1": + return [new PasskeySigner(signer.value)]; + case "Ed25519": + return [Ed25519Signer.fromSecret(ensureEd25519().secret())]; + case "Policy": + return [Ed25519Signer.fromSecret(ensureEd25519().secret()), new PolicySigner(signer.value)]; + } +} + +function signersFor(signWith: SignWith): Signer[] { + switch (signWith) { + case "passkey": + return [adminSigner()]; + case "ed25519": + return [Ed25519Signer.fromSecret(ensureEd25519().secret())]; + case "policy": + // Sample policy co-signs alongside its required Ed25519 co-signer. + return [Ed25519Signer.fromSecret(ensureEd25519().secret()), new PolicySigner(requirePolicy())]; + case "multisig": + return [ + adminSigner(), + Ed25519Signer.fromSecret(ensureEd25519().secret()), + ...(config.samplePolicyId ? [new PolicySigner(config.samplePolicyId)] : []), + ]; + } +} + +export async function fundWallet(amountXlm: number): Promise { + if (!requireConnected()) return; + await run(`Fund wallet (${amountXlm} XLM)`, async () => { + const fundKp = await fundKeypair; + const signer = await fundSigner(); + const native = sac.getSACClient(config.nativeContractId); + const at = await native.transfer({ + to: app.contractId!, + from: fundKp.publicKey(), + amount: BigInt(amountXlm) * 10_000_000n, + }); + await at.signAuthEntries({ + address: fundKp.publicKey(), + signAuthEntry: signer.signAuthEntry, + }); + if (!reportResult(`Fund wallet`, await submit(at))) return; + await refreshBalance(); + }); +} + +export async function refreshBalance(): Promise { + if (!app.contractId) return; + try { + const token = localToken(); + const { result } = await token.balance({ id: app.contractId }); + app.balances[app.selectedToken || config.nativeContractId] = result.toString(); + } catch (err) { + pushLog("error", `Balance read failed: ${describeError(err).message}`); + } +} + +export async function selectToken(contractId: string): Promise { + app.selectedToken = contractId; + await refreshBalance(); +} + +// -- Discovery (both indexer backends) ---------------------------------------- + +export async function discover(backend: IndexerBackend): Promise { + if (!app.contractId) return; + app.discoverBackend = backend; + if (!indexer.configured) { + pushLog("info", `Discovery pending: set VITE_indexerProxyUrl to query ${backend}`); + return; + } + await run(`Discover via ${backend}`, async () => { + const rows = await indexer.getSigners(app.contractId!, backend); + app.discovered = rows; + pushLog("success", `Discovered ${rows.length} signer(s) via ${backend}`, { + detail: rows.map((r) => `${r.key.kind}:${r.status}`).join(", ") || "none", + }); + }); +} + +export async function reverseLookup(backend: IndexerBackend): Promise { + if (!app.keyId) return; + if (!indexer.configured) { + pushLog("info", `Reverse lookup pending: set VITE_indexerProxyUrl`); + return; + } + await run(`Reverse lookup via ${backend}`, async () => { + const ids = await indexer.findWallets( + { kind: "Secp256r1", value: app.keyId! }, + backend, + ); + const match = ids.includes(app.contractId ?? ""); + pushLog(match ? "success" : "info", `keyId → ${ids.length} wallet(s) via ${backend}`, { + detail: ids.length + ? `${ids.map((i) => i.slice(0, 8) + "…").join(", ")}${match ? " · matches connected ✓" : ""}` + : "none", + }); + }); +} + +// -- Ed25519 demo key --------------------------------------------------------- + +/** Generate (once) an ephemeral Ed25519 keypair for the Ed25519/policy demos. */ +export function ensureEd25519(): Keypair { + if (app.ed25519Secret) return Keypair.fromSecret(app.ed25519Secret); + const kp = Keypair.random(); + app.ed25519Secret = kp.secret(); + app.ed25519Public = kp.publicKey(); + pushLog("info", `Generated ephemeral Ed25519 key ${kp.publicKey().slice(0, 8)}…`); + return kp; +} + +// -- Helpers ------------------------------------------------------------------ + +async function signAndSubmit(label: string, at: WalletWriteTx): Promise { + const signed = await account.sign(at, adminSigner()); + return reportResult(label, await submit(signed)); +} + +async function resolvePublicKey(signer: LocalSigner): Promise { + if (signer.publicKey) return base64url.toBuffer(signer.publicKey); + const stored = await storage.get(signer.value).catch(() => null); + return stored?.publicKey; +} + +function requireConnected(): boolean { + if (!isConnected()) { + pushLog("error", "Connect a wallet first"); + return false; + } + return true; +} + +function requirePolicy(): string { + if (!config.samplePolicyId) throw new Error("No sample policy configured (VITE_samplePolicyId)"); + return config.samplePolicyId; +} + +function bytesLabel(bytes: Uint8Array): string { + return `${bytes.length}-byte credential`; +} diff --git a/demo/src/lib/common.ts b/demo/src/lib/common.ts deleted file mode 100644 index 8a298a3..0000000 --- a/demo/src/lib/common.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { PasskeyKit, PasskeyServer, SACClient } from "passkey-kit"; -import { Account, Keypair, StrKey } from "@stellar/stellar-sdk" -import { Buffer } from "buffer"; -import { basicNodeSigner } from "@stellar/stellar-sdk/contract"; -import { Server } from "@stellar/stellar-sdk/rpc"; - -export const rpc = new Server(import.meta.env.VITE_rpcUrl); - -export const mockPubkey = StrKey.encodeEd25519PublicKey(Buffer.alloc(32)) -export const mockSource = new Account(mockPubkey, '0') - -export const fundKeypair = new Promise(async (resolve) => { - const now = new Date(); - - now.setMinutes(0, 0, 0); - - const nowData = new TextEncoder().encode(now.getTime().toString()); - const hashBuffer = await crypto.subtle.digest('SHA-256', nowData); - const keypair = Keypair.fromRawEd25519Seed(Buffer.from(hashBuffer)) - const publicKey = keypair.publicKey() - - rpc.getAccount(publicKey) - .catch(() => rpc.requestAirdrop(publicKey)) - .catch(() => { }) - - resolve(keypair) -}) -export const fundPubkey = (await fundKeypair).publicKey() -export const fundSigner = basicNodeSigner(await fundKeypair, import.meta.env.VITE_networkPassphrase) - -export const account = new PasskeyKit({ - rpcUrl: import.meta.env.VITE_rpcUrl, - networkPassphrase: import.meta.env.VITE_networkPassphrase, - walletWasmHash: import.meta.env.VITE_walletWasmHash, -}); -export const server = new PasskeyServer({ - rpcUrl: import.meta.env.VITE_rpcUrl, - relayerUrl: import.meta.env.VITE_relayerUrl, - relayerApiKey: import.meta.env.VITE_relayerApiKey, - mercuryProjectName: import.meta.env.VITE_mercuryProjectName, - mercuryUrl: import.meta.env.VITE_mercuryUrl, - mercuryJwt: import.meta.env.VITE_mercuryJwt, -}); - -export const sac = new SACClient({ - rpcUrl: import.meta.env.VITE_rpcUrl, - networkPassphrase: import.meta.env.VITE_networkPassphrase, -}); -export const native = sac.getSACClient(import.meta.env.VITE_nativeContractId) \ No newline at end of file diff --git a/demo/src/lib/components/ActivityLog.svelte b/demo/src/lib/components/ActivityLog.svelte new file mode 100644 index 0000000..4076e42 --- /dev/null +++ b/demo/src/lib/components/ActivityLog.svelte @@ -0,0 +1,38 @@ + + +
+
+

Activity

+ +
+ +
+ {#each app.log as entry (entry.id)} +
+
+ {entry.message} + {time(entry.at)} +
+ {#if entry.detail}
{entry.detail}
{/if} + {#if entry.hash} + + {/if} +
+ {:else} +

No activity yet.

+ {/each} +
+
diff --git a/demo/src/lib/components/AddSignerForm.svelte b/demo/src/lib/components/AddSignerForm.svelte new file mode 100644 index 0000000..6671f98 --- /dev/null +++ b/demo/src/lib/components/AddSignerForm.svelte @@ -0,0 +1,89 @@ + + +
+
+ {#each kinds as k} + + {/each} +
+ + {#if kind === "Secp256r1"} +
+ + +
+ {:else if kind === "Ed25519"} +

+ Uses the ephemeral Ed25519 key + {#if app.ed25519Public}{app.ed25519Public.slice(0, 10)}…{:else}(generated on demand){/if}. + +

+ {:else} +

Policy contract {config.samplePolicyId?.slice(0, 10)}…

+ {/if} + +
+
+ {#each [SignerStore.Persistent, SignerStore.Temporary] as s} + + {/each} +
+ + + days +
+ + + + +
diff --git a/demo/src/lib/components/DiscoveryPanel.svelte b/demo/src/lib/components/DiscoveryPanel.svelte new file mode 100644 index 0000000..80185c1 --- /dev/null +++ b/demo/src/lib/components/DiscoveryPanel.svelte @@ -0,0 +1,73 @@ + + +
+

Signer discovery

+ + {#if !indexer.configured} +
+ Indexer proxy not configured — set VITE_indexerProxyUrl to query Mercury / Stellar + Indexer. (Both backends are verified live in the F2 e2e.) +
+ {/if} + +
+ Backend +
+
+ {#each INDEXER_BACKENDS as b} + + {/each} +
+
+
+ +
+ + +
+ + {#if app.discovered.length} +
    + {#each app.discovered as row} +
  • +
    + {row.key.kind} + {row.status} +
    + {shortId(row.key.value, 12)} +

    + {row.storage}{row.expiration ? ` · expires ${new Date(row.expiration * 1000).toISOString().slice(0, 10)}` : ""} +

    +
  • + {/each} +
+ {:else} +

No discovery results yet.

+ {/if} +
diff --git a/demo/src/lib/components/Header.svelte b/demo/src/lib/components/Header.svelte new file mode 100644 index 0000000..a424075 --- /dev/null +++ b/demo/src/lib/components/Header.svelte @@ -0,0 +1,31 @@ + + +
+
+ +
+

Passkey Kit

+

Stellar smart-wallet accounts with WebAuthn passkeys

+
+
+ +
+ {network} + {#if app.busy} + {app.busy}… + {:else if app.status === "connected"} + connected + {:else} + {app.status} + {/if} + {#if !relayer.configured} + no relayer proxy + {/if} + {#if !indexer.configured} + no indexer proxy + {/if} +
+
diff --git a/demo/src/lib/components/LimitsBuilder.svelte b/demo/src/lib/components/LimitsBuilder.svelte new file mode 100644 index 0000000..763408f --- /dev/null +++ b/demo/src/lib/components/LimitsBuilder.svelte @@ -0,0 +1,79 @@ + + +
+ Signer limits +
+ {#each modes as m} + + {/each} +
+

{modes.find((m) => m.id === spec.mode)?.hint}

+ + {#if spec.mode === "restricted"} +
+ {#each spec.entries as entry, i} +
+
+ Contract {i + 1} + +
+ + {#each entry.keys as key, j} +
+ + + +
+ {/each} + + {#if entry.keys.length === 0} +

No keys ⇒ unrestricted on this contract.

+ {/if} +
+ {/each} + +
+ {/if} +
diff --git a/demo/src/lib/components/SignerRow.svelte b/demo/src/lib/components/SignerRow.svelte new file mode 100644 index 0000000..7bb9d5b --- /dev/null +++ b/demo/src/lib/components/SignerRow.svelte @@ -0,0 +1,100 @@ + + +
  • +
    +
    + {signer.kind} + {#if isAdmin(signer)}admin{:else}session{/if} + {#if signer.self}this passkey{/if} + {#if active}active signer{/if} + {signer.store} +
    +
    + + {shortId(signer.value, 12)} +

    + {signer.label ?? ""} · limits: {signer.limitsMode} · expires: {expiryLabel} +

    + +
    + + {#if signer.kind === "Secp256r1" && isAdmin(signer) && !active} + + {/if} + + +
    + + {#if editing} +
    +
    + Update signer +
    +
    + {#each [SignerStore.Persistent, SignerStore.Temporary] as s} + + {/each} +
    + + + days +
    + +
    + + +
    +
    + {/if} +
  • diff --git a/demo/src/lib/components/SignersPanel.svelte b/demo/src/lib/components/SignersPanel.svelte new file mode 100644 index 0000000..fc466cd --- /dev/null +++ b/demo/src/lib/components/SignersPanel.svelte @@ -0,0 +1,35 @@ + + +
    +

    Signers

    + +
      + {#each app.signers as signer (signer.kind + signer.value)} + + {:else} +
    • No signers tracked yet.
    • + {/each} +
    + +
    +

    Add signer

    + + +
    +

    Admin rotation

    +

    Add a fresh unlimited passkey and make it the active signer.

    +
    + + +
    +
    diff --git a/demo/src/lib/components/TransferPanel.svelte b/demo/src/lib/components/TransferPanel.svelte new file mode 100644 index 0000000..ced2b6d --- /dev/null +++ b/demo/src/lib/components/TransferPanel.svelte @@ -0,0 +1,113 @@ + + +
    +

    Balance & transfers

    + +
    + + +
    + +
    +
    +
    {balance ? fromStroops(balance) : "—"}
    +
    {token?.label}
    +
    + +
    + +
    +
    + + +
    +

    Only XLM (native) can be funded; other tokens must be acquired separately.

    + +
    +
    +
    + +
    + + +
    +
    +
    + + +
    +
    + Sign with +
    +
    + {#each signOptions as o} + + {/each} +
    +
    +
    + {#if signWith === "ed25519" || signWith === "policy" || signWith === "multisig"} +

    + Ephemeral Ed25519: + {#if app.ed25519Public}{shortId(app.ed25519Public, 6)}{:else}none{/if} + +

    + {/if} + +
    +
    diff --git a/demo/src/lib/components/WalletPanel.svelte b/demo/src/lib/components/WalletPanel.svelte new file mode 100644 index 0000000..ee4919a --- /dev/null +++ b/demo/src/lib/components/WalletPanel.svelte @@ -0,0 +1,69 @@ + + +
    +

    Wallet

    + + {#if connected} +
    + Smart wallet + {app.contractId} +

    + active signer: {shortId(app.activeKeyId, 10)} +

    +
    +
    + + +
    + {:else} +
    + +
    + + +
    +
    +
    + +
    + {/if} + + {#if app.knownPasskeys.length} +
    + Known passkeys ({app.knownPasskeys.length}) +
      + {#each app.knownPasskeys as pk (pk.keyId)} +
    • +
      + {shortId(pk.keyId, 8)} + {pk.nickname ?? "passkey"} → {shortId(pk.contractId, 6)} +
      + +
    • + {/each} +
    + {/if} +
    diff --git a/demo/src/lib/config.ts b/demo/src/lib/config.ts new file mode 100644 index 0000000..b931508 --- /dev/null +++ b/demo/src/lib/config.ts @@ -0,0 +1,127 @@ +/** + * Public demo configuration + shared singletons. + * + * Everything constructed here is browser-safe. Submission and indexer discovery + * go through server-side proxy workers (URLs below) so no relayer key or indexer + * JWT is ever bundled. The old demo inlined the relayer API key + Mercury JWT and + * a hard-coded Stellar secret seed into the client — all purged. + */ + +import { Buffer } from "buffer"; +import { Account, Keypair, StrKey } from "@stellar/stellar-sdk"; +import { Server } from "@stellar/stellar-sdk/rpc"; +import { basicNodeSigner } from "@stellar/stellar-sdk/contract"; +import { PasskeyKit, SACClient } from "passkey-kit"; +import { LocalStorageAdapter } from "passkey-kit/storage"; +import { RelayerProxyClient } from "./relayer-proxy"; +import { IndexerProxyClient } from "./indexer-proxy"; + +const env = import.meta.env; + +/** A SAC/SEP-41 token the demo can transfer/read. */ +export interface TokenOption { + label: string; + contractId: string; + native: boolean; +} + +function parseTokens(): TokenOption[] { + const tokens: TokenOption[] = [ + { label: "XLM (native)", contractId: env.VITE_nativeContractId, native: true }, + ]; + const extra = env.VITE_extraTokenIds?.trim(); + if (extra) { + for (const raw of extra.split(",")) { + const part = raw.trim(); + if (!part) continue; + const idx = part.indexOf(":"); + const [label, contractId] = + idx > 0 + ? [part.slice(0, idx).trim(), part.slice(idx + 1).trim()] + : [`${part.slice(0, 4)}…${part.slice(-4)}`, part]; + if (contractId) tokens.push({ label, contractId, native: false }); + } + } + return tokens; +} + +/** Typed, public-only config surface. */ +export const config = { + rpcUrl: env.VITE_rpcUrl, + networkPassphrase: env.VITE_networkPassphrase, + walletWasmHash: env.VITE_walletWasmHash, + nativeContractId: env.VITE_nativeContractId, + samplePolicyId: env.VITE_samplePolicyId?.trim() || undefined, + relayerProxyUrl: env.VITE_relayerProxyUrl?.trim() || undefined, + indexerProxyUrl: env.VITE_indexerProxyUrl?.trim() || undefined, + tokens: parseTokens(), +}; + +/** Which network the explorer links point at. */ +export const network: "testnet" | "public" = config.networkPassphrase.includes( + "Test", +) + ? "testnet" + : "public"; + +export const explorerTx = (hash: string): string => + `https://stellar.expert/explorer/${network}/tx/${hash}`; +export const explorerContract = (contractId: string): string => + `https://stellar.expert/explorer/${network}/contract/${contractId}`; + +export const rpc = new Server(config.rpcUrl); + +/** Passkey-record storage (adapter, not hand-rolled localStorage). */ +export const storage = new LocalStorageAdapter(); + +/** The browser-side kit. Signs; never submits (that's the relayer proxy). */ +export const account = new PasskeyKit({ + rpcUrl: config.rpcUrl, + networkPassphrase: config.networkPassphrase, + walletWasmHash: config.walletWasmHash, + storage, +}); + +/** SAC factory for token balance reads + transfers. */ +export const sac = new SACClient({ + rpcUrl: config.rpcUrl, + networkPassphrase: config.networkPassphrase, +}); + +/** Server-side submission (fee-sponsored, keyless — worker mints the key). */ +export const relayer = new RelayerProxyClient(config.relayerProxyUrl); + +/** Server-side signer discovery (Mercury + Stellar Indexer behind the proxy). */ +export const indexer = new IndexerProxyClient(config.indexerProxyUrl); + +/** + * An ephemeral testnet FUNDING source — a deterministic per-hour keypair kept + * topped up by Friendbot. This is NOT a wallet secret: it holds only throwaway + * testnet XLM used to seed freshly created wallets. Regenerated every hour. + */ +export const fundKeypair: Promise = (async () => { + const now = new Date(); + now.setMinutes(0, 0, 0); + const seed = new TextEncoder().encode(now.getTime().toString()); + const hash = await crypto.subtle.digest("SHA-256", seed); + const keypair = Keypair.fromRawEd25519Seed(Buffer.from(hash)); + + rpc + .getAccount(keypair.publicKey()) + .catch(() => rpc.requestAirdrop(keypair.publicKey())) + .catch(() => {}); + + return keypair; +})(); + +export async function fundPubkey(): Promise { + return (await fundKeypair).publicKey(); +} + +export async function fundSigner() { + return basicNodeSigner(await fundKeypair, config.networkPassphrase); +} + +/** A never-funded placeholder source for read-only simulation. */ +export const mockPubkey = StrKey.encodeEd25519PublicKey(Buffer.alloc(32)); +export const mockSource = new Account(mockPubkey, "0"); diff --git a/demo/src/lib/format.ts b/demo/src/lib/format.ts new file mode 100644 index 0000000..137cada --- /dev/null +++ b/demo/src/lib/format.ts @@ -0,0 +1,56 @@ +/** + * Display + typed-error formatting helpers. + */ + +import { ContractError, PasskeyKitError } from "passkey-kit"; + +/** Abbreviate a long id (contract, key, hash) as `ABCDEF…UVWXYZ`. */ +export function shortId(id: string | undefined, edge = 6): string { + if (!id) return ""; + return id.length <= edge * 2 + 1 ? id : `${id.slice(0, edge)}…${id.slice(-edge)}`; +} + +/** Format i128 stroops as a human XLM/token amount (7 decimals). */ +export function fromStroops(stroops: string | bigint | undefined): string { + if (stroops === undefined) return "—"; + const value = Number(BigInt(stroops)) / 10_000_000; + return parseFloat(value.toFixed(7)).toString(); +} + +/** Parse a human token amount into i128 stroops. */ +export function toStroops(amount: string | number): bigint { + const n = typeof amount === "string" ? Number(amount) : amount; + if (!Number.isFinite(n) || n < 0) throw new Error("Invalid amount"); + return BigInt(Math.round(n * 10_000_000)); +} + +/** A structured, human-readable description of any thrown value. */ +export interface ErrorDescription { + name: string; + message: string; + code?: number; + contract?: string; +} + +/** + * Turn any thrown value into a typed, surfacing-friendly description. Recognizes + * the SDK's {@link PasskeyKitError} hierarchy so the UI shows real error codes + * and decoded contract-error names instead of `alert(err.message)`. + */ +export function describeError(err: unknown): ErrorDescription { + if (err instanceof ContractError) { + return { + name: err.name, + message: err.message, + code: err.code, + contract: `#${err.contractCode} ${err.contractErrorName} (${err.family})`, + }; + } + if (err instanceof PasskeyKitError) { + return { name: err.name, message: err.message, code: err.code }; + } + if (err instanceof Error) { + return { name: err.name || "Error", message: err.message }; + } + return { name: "Error", message: String(err) }; +} diff --git a/demo/src/lib/indexer-proxy.ts b/demo/src/lib/indexer-proxy.ts new file mode 100644 index 0000000..2acec07 --- /dev/null +++ b/demo/src/lib/indexer-proxy.ts @@ -0,0 +1,127 @@ +/** + * Browser client for the indexer-proxy (signer discovery, backend toggle). + * + * The proxy holds the Mercury / Stellar Indexer credentials; the browser only + * picks a backend. The JSON wire shape mirrors the SDK's `src/indexer/types.ts` + * `WalletSigner` (B5 / todo 952) — once B5 exports it under a subpath, swap + * {@link DiscoveredSigner} for the SDK type. Values are strings/hex because a + * `SignerKey` class instance and `Uint8Array` can't survive JSON. + */ + +import { + IndexerError, + PasskeyKitErrorCode, + type SignerKeyTag, +} from "passkey-kit"; + +/** The two interchangeable indexer backends. */ +export type IndexerBackend = "mercury" | "stellar-indexer"; + +export const INDEXER_BACKENDS: { id: IndexerBackend; label: string }[] = [ + { id: "mercury", label: "Mercury (Zephyr)" }, + { id: "stellar-indexer", label: "Stellar Indexer" }, +]; + +/** A signer as returned by an indexer backend (JSON wire form). */ +export interface DiscoveredSigner { + key: { kind: SignerKeyTag; value: string }; + /** 65-byte secp256r1 public key, hex-encoded (Secp256r1 signers). */ + publicKey?: string; + /** Expiration as a UNIX timestamp in seconds (inclusive). */ + expiration?: number; + /** Per-contract limits: contract id → signer keys (`null`/absent = unlimited). */ + limits?: Record | null; + storage: "persistent" | "temporary"; + status: "live" | "expired" | "evicted" | "removed"; +} + +const TIMEOUT_MS = 20_000; + +export class IndexerProxyClient { + constructor(private readonly baseUrl?: string) {} + + /** Whether an indexer-proxy URL is configured (discovery possible). */ + get configured(): boolean { + return Boolean(this.baseUrl); + } + + /** Enumerate every signer a backend has indexed for a wallet. */ + async getSigners( + wallet: string, + backend: IndexerBackend, + ): Promise { + const data = await this.get("/signers", { + wallet, + backend, + }); + return Array.isArray(data) ? data : []; + } + + /** Reverse lookup: the wallet contract ids a signer key belongs to. */ + async findWallets( + key: { kind: SignerKeyTag; value: string }, + backend: IndexerBackend, + ): Promise { + const data = await this.get("/wallets", { + kind: key.kind, + value: key.value, + backend, + }); + return Array.isArray(data) ? data : []; + } + + /** Probe backend health (throws IndexerError on transport failure). */ + async health( + backend: IndexerBackend, + ): Promise<{ ok: boolean; backend: string; detail?: string }> { + return this.get("/health", { backend }); + } + + private async get( + path: string, + params: Record, + ): Promise { + if (!this.baseUrl) { + throw new IndexerError( + "Indexer proxy is not configured (set VITE_indexerProxyUrl)", + PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED, + ); + } + + const url = new URL( + path.replace(/^\//, ""), + this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`, + ); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { signal: controller.signal }); + const text = await res.text(); + if (!res.ok) { + throw new IndexerError( + `Indexer proxy ${path} failed (HTTP ${res.status}): ${text.slice(0, 200)}`, + PasskeyKitErrorCode.INDEXER_REQUEST_FAILED, + { path, status: res.status, params }, + ); + } + return (text ? JSON.parse(text) : undefined) as T; + } catch (err) { + if (err instanceof IndexerError) throw err; + const aborted = err instanceof Error && err.name === "AbortError"; + throw new IndexerError( + aborted + ? `Indexer proxy ${path} timed out` + : `Indexer proxy ${path} failed: ${ + err instanceof Error ? err.message : String(err) + }`, + PasskeyKitErrorCode.INDEXER_REQUEST_FAILED, + { path, params }, + err instanceof Error ? err : undefined, + ); + } finally { + clearTimeout(timeout); + } + } +} diff --git a/demo/src/lib/relayer-proxy.ts b/demo/src/lib/relayer-proxy.ts new file mode 100644 index 0000000..5c9a928 --- /dev/null +++ b/demo/src/lib/relayer-proxy.ts @@ -0,0 +1,148 @@ +/** + * Browser client for the relayer-proxy worker (todo 954). + * + * The worker holds the relayer API key (keyless per-IP minting); the browser + * sends only transaction material and receives a typed result. Two wire bodies, + * mirroring `PasskeyServer.send`'s routing: + * - `{ func, auth }` — a Soroban invoke without source-account auth. + * - `{ xdr }` — an envelope (deploys / source-account auth) to fee-bump. + */ + +import { + PasskeyKitErrorCode, + RelayerError, + contractErrorFromCode, + decodeContractError, + type PasskeyKitError, + type TransactionResult, +} from "passkey-kit"; + +/** `{ func, auth }` (preferred) or `{ xdr }` (envelope) wire body. */ +export type RelayerProxyBody = + | { func: string; auth: string[] } + | { xdr: string }; + +/** Shape the worker returns on a non-2xx response. */ +interface WorkerError { + message?: string; + code?: string; + contractCode?: number; + details?: unknown; +} + +const TIMEOUT_MS = 6 * 60 * 1000; + +export class RelayerProxyClient { + constructor(private readonly baseUrl?: string) {} + + /** Whether a proxy URL is configured (submission possible). */ + get configured(): boolean { + return Boolean(this.baseUrl); + } + + /** + * Submit transaction material through the proxy. Never throws — returns a + * discriminated {@link TransactionResult}, matching the SDK's submission + * contract so the UI can branch on `result.success`. + */ + async submit(body: RelayerProxyBody): Promise { + if (!this.baseUrl) { + return fail( + new RelayerError( + "Relayer proxy is not configured (set VITE_relayerProxyUrl)", + PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED, + ), + ); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const res = await fetch(this.baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + const text = await res.text(); + const payload = text ? safeParse(text) : undefined; + + if (!res.ok) { + return fail(toError(payload, res.status, text)); + } + + const ok = (payload ?? {}) as { + hash?: string; + ledger?: number; + transactionId?: string; + }; + if (!ok.hash) { + return fail( + new RelayerError( + `Relayer proxy returned no transaction hash: ${text.slice(0, 200)}`, + PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, + ), + ); + } + return { + success: true, + hash: ok.hash, + ledger: ok.ledger, + transactionId: ok.transactionId, + }; + } catch (err) { + const aborted = err instanceof Error && err.name === "AbortError"; + return fail( + new RelayerError( + aborted + ? "Relayer proxy request timed out" + : `Relayer proxy request failed: ${ + err instanceof Error ? err.message : String(err) + }`, + PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, + ), + ); + } finally { + clearTimeout(timeout); + } + } +} + +function fail(error: PasskeyKitError): TransactionResult { + return { success: false, error }; +} + +function safeParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** Map a worker error payload to a typed SDK error (ContractError when decodable). */ +function toError( + payload: unknown, + status: number, + raw: string, +): PasskeyKitError { + const e = + (payload as { error?: WorkerError })?.error ?? (payload as WorkerError); + const message = + e?.message ?? `Relayer proxy failed (HTTP ${status}): ${raw.slice(0, 200)}`; + + // Prefer an explicit contract code, else scan the raw body for Error(Contract, #N). + const contract = + (typeof e?.contractCode === "number" + ? contractErrorFromCode(e.contractCode, { status, details: e.details }) + : null) ?? decodeContractError(raw); + if (contract) return contract; + + return new RelayerError(message, PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, { + status, + code: e?.code, + details: e?.details, + }); +} diff --git a/demo/src/lib/signers.ts b/demo/src/lib/signers.ts new file mode 100644 index 0000000..1c9b18c --- /dev/null +++ b/demo/src/lib/signers.ts @@ -0,0 +1,96 @@ +/** + * Local signer model + `SignerLimits` construction. + * + * The demo keeps its own optimistic registry of the signers it has added, so the + * core flows work before the indexer proxy is live; the Discovery panel then + * cross-checks the registry against a real backend. A signer's `limits` are what + * define "admin" vs "session": an UNLIMITED signer (`SignerLimits` = None) can + * authorize anything (admin); a restricted or empty map is a scoped session key. + */ + +import { SignerKey, SignerStore, type SignerKeyTag, type SignerLimits } from "passkey-kit"; + +export type SignerKind = SignerKeyTag; + +/** Limits presets exposed by the builder. */ +export type LimitsMode = "unlimited" | "none" | "restricted"; + +/** One restricted-limits row: which signer keys this signer may co-authorize on a contract. */ +export interface LimitsEntry { + contract: string; + keys: { kind: SignerKind; value: string }[]; +} + +/** UI model for building a `SignerLimits`. */ +export interface LimitsSpec { + mode: LimitsMode; + entries: LimitsEntry[]; +} + +export const UNLIMITED: LimitsSpec = { mode: "unlimited", entries: [] }; + +/** A signer the demo knows about (optimistic registry). */ +export interface LocalSigner { + kind: SignerKind; + /** keyId (base64url) for Secp256r1, G-address for Ed25519, C-address for Policy. */ + value: string; + store: SignerStore; + limitsMode: LimitsMode; + expiration?: number; + label?: string; + /** True when this is the connected passkey. */ + self?: boolean; + /** 65-byte secp256r1 public key (base64url), needed to `update_signer`. */ + publicKey?: string; +} + +/** Whether a signer is an unlimited "admin" signer. */ +export function isAdmin(signer: { limitsMode: LimitsMode }): boolean { + return signer.limitsMode === "unlimited"; +} + +/** Build the SDK {@link SignerKey} for a local signer (for remove/lookup). */ +export function toSignerKey(signer: { kind: SignerKind; value: string }): SignerKey { + switch (signer.kind) { + case "Secp256r1": + return SignerKey.Secp256r1(signer.value); + case "Ed25519": + return SignerKey.Ed25519(signer.value); + case "Policy": + return SignerKey.Policy(signer.value); + } +} + +/** Translate a UI {@link LimitsSpec} into the SDK's {@link SignerLimits}. */ +export function buildSignerLimits(spec: LimitsSpec): SignerLimits { + if (spec.mode === "unlimited") return undefined; + + const map = new Map(); + if (spec.mode === "none") return map; // empty map = fail-closed (no permissions) + + for (const entry of spec.entries) { + if (!entry.contract) continue; + map.set( + entry.contract, + entry.keys.length ? entry.keys.map(toSignerKey) : undefined, + ); + } + return map; +} + +/** Human summary of a limits spec for badges/log. */ +export function describeLimits(spec: LimitsSpec): string { + switch (spec.mode) { + case "unlimited": + return "unlimited (admin)"; + case "none": + return "no permissions (fail-closed)"; + case "restricted": + return `restricted · ${spec.entries.length} contract${spec.entries.length === 1 ? "" : "s"}`; + } +} + +/** Absolute UNIX-second expiration `days` from now (contract stores timestamps). */ +export function expirationInDays(days: number): number { + return Math.floor(Date.now() / 1000) + Math.round(days * 86_400); +} diff --git a/demo/src/lib/state.svelte.ts b/demo/src/lib/state.svelte.ts new file mode 100644 index 0000000..57df2ea --- /dev/null +++ b/demo/src/lib/state.svelte.ts @@ -0,0 +1,90 @@ +/** + * Shared reactive application state (Svelte 5 runes). + * + * A single deeply-reactive `$state` object mutated by `actions.ts` and read by + * components. Kept intentionally small — flows live in `actions.ts`, config + + * singletons in `config.ts`. + */ + +import type { StoredPasskey } from "passkey-kit"; +import type { LocalSigner } from "./signers"; +import type { DiscoveredSigner, IndexerBackend } from "./indexer-proxy"; + +export type ConnectionStatus = "disconnected" | "connecting" | "connected"; +export type LogLevel = "info" | "success" | "error"; + +export interface LogEntry { + id: number; + at: number; + level: LogLevel; + message: string; + /** Transaction hash → block explorer link. */ + hash?: string; + /** Secondary line: error code / contract error / ids. */ + detail?: string; +} + +export interface AppState { + ready: boolean; + status: ConnectionStatus; + /** Connected passkey keyId (base64url). */ + keyId?: string; + /** Connected wallet contract id (C…). */ + contractId?: string; + /** Passkey keyId currently used to authorize writes (admin rotation target). */ + activeKeyId?: string; + /** Optimistic registry of known signers. */ + signers: LocalSigner[]; + /** Passkeys persisted in the storage adapter (for reconnect UI). */ + knownPasskeys: StoredPasskey[]; + /** Token contract id → balance (stroops). */ + balances: Record; + /** Selected token for transfers/balance. */ + selectedToken: string; + /** Discovery results from the last indexer query. */ + discovered: DiscoveredSigner[]; + discoverBackend: IndexerBackend; + /** Runtime-generated ephemeral Ed25519 keypair (demo only; never persisted). */ + ed25519Secret?: string; + ed25519Public?: string; + /** Label of the in-flight operation, or null when idle. */ + busy: string | null; + log: LogEntry[]; +} + +export const app = $state({ + ready: false, + status: "disconnected", + signers: [], + knownPasskeys: [], + balances: {}, + selectedToken: "", + discovered: [], + discoverBackend: "mercury", + busy: null, + log: [], +}); + +let logSeq = 0; + +/** Append a log entry (newest first, capped). */ +export function pushLog( + level: LogLevel, + message: string, + extra?: { hash?: string; detail?: string }, +): void { + app.log.unshift({ + id: ++logSeq, + at: Date.now(), + level, + message, + hash: extra?.hash, + detail: extra?.detail, + }); + if (app.log.length > 80) app.log.length = 80; +} + +/** True when a wallet is connected. */ +export function isConnected(): boolean { + return app.status === "connected" && Boolean(app.contractId); +} diff --git a/demo/src/lib/submit.ts b/demo/src/lib/submit.ts new file mode 100644 index 0000000..a78add8 --- /dev/null +++ b/demo/src/lib/submit.ts @@ -0,0 +1,95 @@ +/** + * Client-side submission routing. + * + * This mirrors `PasskeyServer.send`'s logic — the routing the server-only SDK + * entry performs — but keeps it in the browser and forwards the transaction + * material to the relayer-proxy worker instead of holding a relayer key. An + * `invokeHostFunction` without source-account auth uses the preferred + * `{ func, auth }` Soroban path; everything else (deploys, source-account auth) + * is fee-bumped through the `{ xdr }` envelope path. + */ + +import { + Operation, + TransactionBuilder, + type Transaction, +} from "@stellar/stellar-sdk"; +import { AssembledTransaction } from "@stellar/stellar-sdk/contract"; +import { + RelayerError, + PasskeyKitErrorCode, + type TransactionResult, +} from "passkey-kit"; +import { config, relayer } from "./config"; + +// `any` in the generic slot: submission only touches `.built`, which is +// independent of the result type, and `AssembledTransaction` is invariant in its +// type parameter — so accept whatever the kit/SAC builders return. +type Submittable = AssembledTransaction | Transaction | string; + +/** Normalize any submittable input to a built {@link Transaction}. */ +function toBuiltTransaction(input: Submittable): Transaction { + if (typeof input === "string") { + return TransactionBuilder.fromXDR( + input, + config.networkPassphrase, + ) as Transaction; + } + if (input instanceof AssembledTransaction) { + if (!input.built) { + throw new RelayerError( + "AssembledTransaction has not been simulated/built yet", + PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, + ); + } + return input.built; + } + return input; +} + +/** Whether any invokeHostFunction op carries source-account auth. */ +function hasSourceAccountAuth(transaction: Transaction): boolean { + for (const op of transaction.operations) { + if (op.type !== "invokeHostFunction") continue; + for (const entry of (op as Operation.InvokeHostFunction).auth ?? []) { + if ( + entry.credentials().switch().name === "sorobanCredentialsSourceAccount" + ) { + return true; + } + } + } + return false; +} + +/** + * Submit a signed transaction via the relayer proxy. Never throws — returns a + * discriminated {@link TransactionResult}. + */ +export async function submit(input: Submittable): Promise { + let built: Transaction; + try { + built = toBuiltTransaction(input); + } catch (err) { + return { + success: false, + error: + err instanceof RelayerError + ? err + : new RelayerError( + err instanceof Error ? err.message : String(err), + PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, + ), + }; + } + + const op = built.operations[0]; + if (op?.type === "invokeHostFunction" && !hasSourceAccountAuth(built)) { + const invokeOp = op as Operation.InvokeHostFunction; + const func = invokeOp.func.toXDR("base64"); + const auth = (invokeOp.auth ?? []).map((entry) => entry.toXDR("base64")); + return relayer.submit({ func, auth }); + } + + return relayer.submit({ xdr: built.toXDR() }); +} diff --git a/demo/src/main.ts b/demo/src/main.ts index 4d67e2a..a575b3b 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -1,8 +1,16 @@ -import './app.css' -import App from './App.svelte' +import { Buffer } from "buffer"; +import { mount } from "svelte"; +import "./app.css"; +import App from "./App.svelte"; -const app = new App({ - target: document.getElementById('app')!, -}) +// stellar-sdk / passkey-kit use Node's `Buffer` for XDR + base64url work; the +// browser has no global for it. Polyfill before anything else loads. +if (typeof globalThis.Buffer === "undefined") { + globalThis.Buffer = Buffer; +} -export default app +const app = mount(App, { + target: document.getElementById("app")!, +}); + +export default app; diff --git a/demo/src/vite-env.d.ts b/demo/src/vite-env.d.ts index 45c1e21..6580ed0 100644 --- a/demo/src/vite-env.d.ts +++ b/demo/src/vite-env.d.ts @@ -1,19 +1,46 @@ /// /// +/** + * PUBLIC-ONLY demo configuration. + * + * Every value here is safe to ship in the browser bundle. There are deliberately + * NO secrets: the relayer API key and indexer JWTs live only in the server-side + * proxy worker (todo 954), which the demo reaches over `VITE_relayerProxyUrl` / + * `VITE_indexerProxyUrl`. Never add a `VITE_`-prefixed secret here. + */ interface ImportMetaEnv { - readonly VITE_walletWasmHash: string; - readonly VITE_networkPassphrase: string; - readonly VITE_nativeContractId: string; - readonly VITE_rpcUrl: string; - readonly VITE_relayerUrl: string; - readonly VITE_relayerApiKey: string; - readonly VITE_mercuryProjectName: string; - readonly VITE_mercuryUrl: string; - readonly VITE_mercuryJwt: string; - readonly VITE_mercuryKey: string; + /** Stellar RPC URL (e.g. https://soroban-testnet.stellar.org). */ + readonly VITE_rpcUrl: string; + /** Network passphrase (e.g. "Test SDF Network ; September 2015"). */ + readonly VITE_networkPassphrase: string; + /** Smart-wallet WASM hash (hex), pinned from the deployments manifest. */ + readonly VITE_walletWasmHash: string; + /** Native XLM Stellar Asset Contract id (C…). */ + readonly VITE_nativeContractId: string; + /** + * Optional non-native SAC / SEP-41 token ids to offer in the token picker, + * comma-separated `label:C…` (or bare `C…`). Exercises non-native SAC support. + */ + readonly VITE_extraTokenIds?: string; + /** + * Optional deployed `sample-policy` contract id (C…) for the policy-signer + * demo. Deploy an instance of the v1 sample policy and set this; when unset, + * the policy-signer controls are disabled with a hint. + */ + readonly VITE_samplePolicyId?: string; + /** + * Relayer-proxy worker base URL (submission). The worker holds the relayer + * key. When unset, submission is disabled with a "pending backend" notice. + */ + readonly VITE_relayerProxyUrl?: string; + /** + * Indexer-proxy base URL (signer discovery). The worker holds the Mercury / + * Stellar Indexer credentials. When unset, discovery shows "pending backend". + */ + readonly VITE_indexerProxyUrl?: string; } interface ImportMeta { - readonly env: ImportMetaEnv; + readonly env: ImportMetaEnv; } diff --git a/demo/tsconfig.json b/demo/tsconfig.json index 786803d..fa7a4dd 100644 --- a/demo/tsconfig.json +++ b/demo/tsconfig.json @@ -4,9 +4,14 @@ "target": "ESNext", "useDefineForClassFields": true, "module": "ESNext", + "moduleResolution": "bundler", "resolveJsonModule": true, + "allowImportingTsExtensions": false, "isolatedModules": true, + "strict": true, + "skipLibCheck": true, + "types": ["svelte", "vite/client"] }, - "include": ["src/**/*.ts", "src/**/*.svelte"], + "include": ["src/**/*.ts", "src/**/*.svelte", "src/**/*.svelte.ts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/demo/vite.config.ts b/demo/vite.config.ts index c543c8d..444b8bd 100644 --- a/demo/vite.config.ts +++ b/demo/vite.config.ts @@ -1,10 +1,25 @@ -import { defineConfig } from 'vite' -import { svelte } from '@sveltejs/vite-plugin-svelte' +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; -// https://vitejs.dev/config/ +// https://vite.dev/config/ export default defineConfig({ plugins: [svelte()], + define: { + // stellar-sdk / passkey-kit expect a Node-style `global`; map it to the + // browser realm. `Buffer` itself is polyfilled at runtime in `main.ts`. + global: "globalThis", + }, + resolve: { + // The demo links `passkey-kit` via `link:..`, so passkey-kit's compiled + // `dist/` and the demo resolve `@stellar/stellar-sdk`/`buffer` from two + // physical locations. Dedupe to a single instance so `xdr`/`Buffer` + // `instanceof` checks hold across the boundary. + dedupe: ["@stellar/stellar-sdk", "buffer"], + }, + optimizeDeps: { + include: ["@stellar/stellar-sdk", "buffer", "base64url"], + }, build: { - target: "ESNext" + target: "esnext", }, -}) +}); From ba8ff78de690a21b5cc8f9b43e1d9f4856fe7ce0 Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 22:22:54 -0400 Subject: [PATCH 14/30] fix(contracts)!: audit-gate remediation FIX-1..7 (policy lifecycle hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the 3-way audit gate (todo 958). Core auth/WebAuthn/storage/ upgrade/derivation were cleared; findings concentrated in the policy lifecycle. 64 tests green. - FIX-1 [HIGH]: remove untrusted uninstall from remove_signer critical path. Removal is now pure wallet state + SignerRemoved event — no policy code runs. try_uninstall could not catch non-recoverable Budget/Storage ExceededLimit, so a budget-exhausting policy could unwind the atomic tx and roll back its own removal. Policies self-clean via the now-permissionless uninstall entrypoint, which verifies get_signer(Policy(self))==None first. New test malicious_uninstall_cannot_block_removal proves removal survives a budget-exhausting policy. - FIX-2 [MED]: policy__ caller authentication. Interface documents the public-callability contract (impls MUST be read-only OR source.require_auth). Confirmed require_auth works inside __check_auth via invoker auth (ed25519_with_policy_limits_full_stack); sample-policy uses it and sample_policy_rejects_spoofed_caller proves external callers are blocked. - FIX-3 [MED]: sample-policy is now deny-by-default — fails closed on arg shape/type, gates only transfer within cap, rejects the wallet's own admin surface and all non-contract contexts. - FIX-4 [MED]: sample-policy renews its instance/code TTL and the Installed(wallet) key on install and every successful policy__. - FIX-5 [MED]: required co-signers are scope-independent approvers. Dropped the recursive enforcement of stored policy-limit-key limits (and the depth guard — no recursion, no cycle). Symmetric across key kinds; Some(empty) disables INDEPENDENT coverage only. Documented in types.rs; tests nonpolicy/policy_co_signer_own_limits_not_enforced. - FIX-6 [LOW]: storage.rs uses saturating_sub for max_ttl - WEEK_OF_LEDGERS. - FIX-7 [LOW]: policy__ documented side-effect-free (or require_auth); UP-only default and UV rationale documented on UserPresenceRequired. Co-Authored-By: Claude Fable 5 --- contracts/sample-policy/src/lib.rs | 127 ++++++++++++++---- contracts/smart-wallet-interface/src/lib.rs | 60 +++++++-- contracts/smart-wallet-interface/src/types.rs | 42 ++++-- contracts/smart-wallet/src/context.rs | 67 ++++----- contracts/smart-wallet/src/lib.rs | 16 ++- contracts/smart-wallet/src/storage.rs | 15 ++- .../smart-wallet/src/tests/test_admin.rs | 54 ++++++-- contracts/smart-wallet/src/tests/test_auth.rs | 85 ++++++++++-- .../src/tests/test_integration.rs | 53 +++++++- 9 files changed, 395 insertions(+), 124 deletions(-) diff --git a/contracts/sample-policy/src/lib.rs b/contracts/sample-policy/src/lib.rs index 4b43a16..e5601e0 100644 --- a/contracts/sample-policy/src/lib.rs +++ b/contracts/sample-policy/src/lib.rs @@ -1,19 +1,31 @@ -//! Sample policy demonstrating the smart wallet policy lifecycle. +//! Sample policy demonstrating the smart wallet policy lifecycle SAFELY. //! -//! `policy__` is publicly callable — anyone can invoke it with arbitrary -//! arguments — so a policy holding any per-wallet state MUST gate on wallets -//! that actually installed it. The wallet invokes `install`/`uninstall` as -//! the direct cross-contract caller, so `wallet.require_auth()` inside the -//! hooks passes via invoker auth for legitimate calls and requires a real -//! wallet authorization for anyone else. +//! It is a reference others copy, so it models every hardening the audit +//! asked for: //! -//! This sample rejects `transfer` invocations over 10_000_000 stroops for -//! installed wallets, and rejects everything for wallets that never -//! installed it. +//! - **Caller authentication (FIX-2).** `policy__` is publicly callable with +//! an attacker-chosen `source`. Because this policy keeps per-wallet state, +//! it calls `source.require_auth()` first. During a real `__check_auth` the +//! wallet is the direct invoker of `policy__`, so invoker auth satisfies +//! this; an external caller cannot satisfy it for a wallet it does not +//! control. The `Installed(source)` marker is a secondary gate (it proves +//! past installation, never current caller identity). +//! - **Deny-by-default (FIX-3).** Every context is rejected unless it is a +//! `transfer` within the cap. Any other function, any non-contract context, +//! a missing/mistyped amount argument, or a context targeting the wallet's +//! own admin surface all FAIL CLOSED. +//! - **TTL renewal (FIX-4).** `install` and every successful `policy__` +//! extend this policy's instance/code TTL and the `Installed(wallet)` key, +//! so a policy participating in a wallet's authorization cannot silently +//! archive into a wallet lock. +//! - **Permissionless self-clean (FIX-1).** The wallet does NOT call +//! `uninstall` on removal. Anyone may call it; it clears `Installed(wallet)` +//! only after confirming (via the wallet's own `get_signer`) that this +//! policy is genuinely no longer a signer on that wallet. #![no_std] -use smart_wallet_interface::{types::SignerKey, PolicyInterface}; +use smart_wallet_interface::{types::SignerKey, PolicyInterface, SmartWalletClient}; use soroban_sdk::{ auth::{Context, ContractContext}, contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, @@ -24,12 +36,24 @@ use soroban_sdk::{ #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] pub enum PolicyError { + /// A context is not permitted (deny-by-default). NotAllowed = 1, + /// `policy__` was called for a wallet that never installed this policy. NotInstalled = 2, + /// `uninstall` was called while this policy is still a signer on the + /// wallet. + StillInstalled = 3, } +/// Maximum per-`transfer` amount this policy permits. const TRANSFER_LIMIT: i128 = 10_000_000; +/// TTL renewal parameters (in ledgers at the historical 5s close time): bump +/// to ~30 days whenever remaining TTL drops below ~1 week. Both are well under +/// any real network's `max_ttl`. +const RENEW_THRESHOLD: u32 = 60 * 60 * 24 / 5 * 7; +const RENEW_TO: u32 = 60 * 60 * 24 / 5 * 30; + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum StorageKey { @@ -42,15 +66,29 @@ pub struct Contract; #[contractimpl] impl PolicyInterface for Contract { fn install(env: Env, wallet: Address) { + // The wallet is the direct invoker during add_signer; invoker auth. wallet.require_auth(); + let key = StorageKey::Installed(wallet); env.storage() .persistent() - .set::(&StorageKey::Installed(wallet), &true); + .set::(&key, &true); + + renew_ttls(&env, &key); } fn uninstall(env: Env, wallet: Address) { - wallet.require_auth(); + // Permissionless (FIX-1): clear install-state only once this policy is + // genuinely no longer a signer on `wallet`. The wallet's get_signer is + // a read-only view; a griefer cannot clear state for a wallet where + // this policy is still installed. + let still_signer = SmartWalletClient::new(&env, &wallet) + .get_signer(&SignerKey::Policy(env.current_contract_address())) + .is_some(); + + if still_signer { + panic_with_error!(&env, PolicyError::StillInstalled); + } env.storage() .persistent() @@ -58,29 +96,62 @@ impl PolicyInterface for Contract { } fn policy__(env: Env, source: Address, _signer: SignerKey, contexts: Vec) { - if !env - .storage() - .persistent() - .has::(&StorageKey::Installed(source)) - { - panic_with_error!(&env, PolicyError::NotInstalled) + // FIX-2: authenticate the caller really is the wallet before touching + // any per-wallet state. Satisfied by invoker auth during __check_auth. + source.require_auth(); + + let key = StorageKey::Installed(source.clone()); + if !env.storage().persistent().has::(&key) { + panic_with_error!(&env, PolicyError::NotInstalled); } + // FIX-4: keep this policy and its install marker alive for as long as + // it is actively authorizing. + renew_ttls(&env, &key); + + // FIX-3: deny-by-default. Anything not explicitly permitted rejects. for context in contexts.iter() { match context { - Context::Contract(ContractContext { fn_name, args, .. }) => { - if fn_name == symbol_short!("transfer") { - if let Some(amount_val) = args.get(2) { - if let Ok(amount) = i128::try_from_val(&env, &amount_val) { - if amount > TRANSFER_LIMIT { - panic_with_error!(&env, PolicyError::NotAllowed) - } - } - } + Context::Contract(ContractContext { + contract, + fn_name, + args, + }) => { + // Never independently authorize the wallet's own admin + // surface (add/update/remove/upgrade). `source` is the + // wallet. + if contract == source { + panic_with_error!(&env, PolicyError::NotAllowed); + } + + // Only `transfer` is permitted. + if fn_name != symbol_short!("transfer") { + panic_with_error!(&env, PolicyError::NotAllowed); + } + + // Fail closed if the amount argument is missing or not an + // i128 (previously this fell open). + let amount = match args.get(2).and_then(|v| i128::try_from_val(&env, &v).ok()) { + Some(amount) => amount, + None => panic_with_error!(&env, PolicyError::NotAllowed), + }; + + if amount <= 0 || amount > TRANSFER_LIMIT { + panic_with_error!(&env, PolicyError::NotAllowed); } } + // Non-contract contexts (deploys, etc.) are never permitted. _ => panic_with_error!(&env, PolicyError::NotAllowed), } } } } + +fn renew_ttls(env: &Env, installed_key: &StorageKey) { + env.storage() + .instance() + .extend_ttl(RENEW_THRESHOLD, RENEW_TO); + env.storage() + .persistent() + .extend_ttl::(installed_key, RENEW_THRESHOLD, RENEW_TO); +} diff --git a/contracts/smart-wallet-interface/src/lib.rs b/contracts/smart-wallet-interface/src/lib.rs index c6ac48f..5d8b02e 100644 --- a/contracts/smart-wallet-interface/src/lib.rs +++ b/contracts/smart-wallet-interface/src/lib.rs @@ -18,9 +18,11 @@ pub trait SmartWalletInterface { /// Replace an existing signer's value and/or storage durability. /// Requires wallet auth. Fails if the signer key does not exist. fn update_signer(env: Env, signer: Signer) -> Result<(), Error>; - /// Remove a signer. Requires wallet auth. Policy signers get their - /// `uninstall` hook invoked (best-effort: a failing or malicious policy - /// cannot block its own removal). + /// Remove a signer. Requires wallet auth. Removal is pure wallet state: + /// NO policy code runs on this path, so a malicious or broken policy can + /// never block its own removal. Policy signers self-clean their + /// install-state afterwards via the permissionless `PolicyInterface:: + /// uninstall` entrypoint. fn remove_signer(env: Env, signer_key: SignerKey) -> Result<(), Error>; /// Replace the contract's wasm. Requires wallet auth. Emits `Upgraded`. fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error>; @@ -36,19 +38,51 @@ pub trait PolicyInterface { /// `source` is the wallet, `signer` is the signer key the check is being /// performed for, `contexts` is what is being authorized (the full /// context list when the policy is used as a signature; a single context - /// when the policy is used inside another signer's limits). Panic to - /// reject. + /// per call when the policy is used inside another signer's limits). + /// Panic to reject. /// - /// PUBLICLY CALLABLE: anyone can invoke `policy__` with arbitrary - /// arguments. Stateful policies MUST gate on wallets that actually - /// installed them (see `install`) and MUST NOT trust `source` otherwise. + /// ## Caller authentication (audit FIX-2) + /// + /// `policy__` is PUBLICLY CALLABLE — anyone can invoke it with an + /// arbitrary `source` and `contexts`. Checking an "installed" marker for + /// `source` proves only that `source` installed the policy at some point, + /// NOT that `source` is the current caller. Every implementation MUST be + /// one of: + /// + /// - **Read-only / side-effect-free** — safe to call with any arguments + /// because a spoofed call cannot change anything (recommended for + /// stateless rules). Benign self-maintenance (extending the policy's own + /// TTLs) is permitted and does not count as a security-relevant side + /// effect. + /// - **Caller-authenticated** — call `source.require_auth()` before any + /// security-relevant state change (spending counters, allowances, …). + /// During a legitimate check the wallet is the DIRECT invoker of + /// `policy__`, so `source.require_auth()` is satisfied by invoker auth; + /// a spoofed external caller cannot satisfy it for a wallet it does not + /// control. (Verified to work inside `__check_auth` — see + /// `sample-policy`.) + /// + /// ## Determinism (audit FIX-7) + /// + /// Prefer side-effect-free policies. The wallet's pass-1 coverage search + /// stops at the first covering signer and iterates the signatures map in + /// host ScVal order, so a stateful policy used inside limits can observe + /// order-dependent invocation. A read-only (or idempotent) policy is + /// immune to this. fn policy__(env: Env, source: Address, signer: SignerKey, contexts: Vec); /// Lifecycle hook invoked by a wallet when this policy is added as a - /// signer. Implementations should `wallet.require_auth()` — the wallet is - /// the direct invoker, so invoker auth makes this trustworthy — and set - /// up any per-wallet state. + /// signer (from `add_signer`/`__constructor`). The wallet is the direct + /// invoker, so `wallet.require_auth()` here is satisfied by invoker auth + /// and authenticates the wallet. This is a HARD call — a panic aborts the + /// `add_signer`, so a policy can legitimately refuse to be installed. + /// Set up any per-wallet state here. fn install(env: Env, wallet: Address); - /// Lifecycle hook invoked by a wallet when this policy is removed. The - /// wallet invokes this best-effort: a panic here does not block removal. + /// PERMISSIONLESS self-clean entrypoint (audit FIX-1). The wallet does + /// NOT call this on removal; anyone may call it at any time. An + /// implementation MUST verify the policy is genuinely no longer a signer + /// on `wallet` — e.g. `SmartWalletClient::new(env, + /// wallet).get_signer(SignerKey::Policy(current_contract)).is_none()` — + /// before clearing any per-wallet install-state, so a griefer cannot + /// clear state for a wallet where the policy is still installed. fn uninstall(env: Env, wallet: Address); } diff --git a/contracts/smart-wallet-interface/src/types.rs b/contracts/smart-wallet-interface/src/types.rs index de435b7..f76393c 100644 --- a/contracts/smart-wallet-interface/src/types.rs +++ b/contracts/smart-wallet-interface/src/types.rs @@ -41,10 +41,16 @@ pub enum Error { /// authenticatorData is shorter than the WebAuthn minimum of 37 bytes /// (rpIdHash 32 + flags 1 + signCount 4). InvalidAuthenticatorData = 124, - /// The authenticator did not set the User Present (UP) flag. User - /// Verification (UV) is deliberately NOT required; requiring UP only - /// keeps silent, non-interactive assertions out while remaining - /// compatible with authenticators that don't do user verification. + /// The authenticator did not set the User Present (UP) flag. + /// + /// UP-only is the deliberate default (audit FIX-7). Requiring UP keeps + /// silent, non-interactive assertions out while staying compatible with + /// authenticators that cannot do User Verification (UV — biometric/PIN). + /// UV is therefore NOT required by this contract. A deployment that wants + /// UV-required assertions should enforce it at the client/relayer layer, + /// or via a future per-signer flag (which would be a signer-model change, + /// not a change to this check); the contract cannot upgrade UP-only + /// signers to UV-required retroactively without such a flag. UserPresenceRequired = 125, } @@ -71,12 +77,28 @@ pub struct SignerExpiration(pub Option); /// - `Some({address -> None})`: the signer may authorize any invocation of /// contract `address`, with no co-signers required. /// - `Some({address -> Some([keys])})`: the signer may authorize invocations -/// of contract `address` only if every listed key also passes: non-policy -/// keys must be present in the transaction's signatures map (and therefore -/// fully verified in pass 2 of `__check_auth`); policy keys are invoked via -/// `policy__` for the specific context (and, if the policy key is also -/// stored on this wallet, its own stored limits are verified recursively, -/// bounded by a fixed depth guard). +/// of contract `address` only if every listed key also APPROVES. The listed +/// keys are required CO-SIGNERS. +/// +/// ## Required co-signers are scope-independent approvers (audit FIX-5) +/// +/// A required co-signer's OWN `SignerLimits` do NOT constrain its co-signer +/// role — a key's limits govern only its INDEPENDENT authority (whether it can +/// cover a context on its own). This is symmetric across key kinds: +/// +/// - A non-policy required key must be present in the transaction's signatures +/// map (and is therefore fully verified — stored, unexpired, crypto-valid — +/// in pass 2 of `__check_auth`). Its own limits are not consulted. +/// - A policy required key must APPROVE the specific context via `policy__` +/// (it need not appear in the signatures map). If the policy key is also +/// stored on this wallet it must be unexpired, but its own stored limits are +/// NOT recursively enforced. +/// +/// Consequence: `Some(empty map)` on a key disables that key's INDEPENDENT +/// coverage only. A key with empty limits can still serve as a required +/// co-signer for another signer. Because no stored policy's limits are +/// re-entered, there is no policy-limit recursion (and thus no cycle to +/// guard against). /// /// Notes: /// - Deploy permission is NOT grantable through limits: `CreateContract*` diff --git a/contracts/smart-wallet/src/context.rs b/contracts/smart-wallet/src/context.rs index f5ca9d3..f4a7819 100644 --- a/contracts/smart-wallet/src/context.rs +++ b/contracts/smart-wallet/src/context.rs @@ -1,5 +1,5 @@ use smart_wallet_interface::{ - types::{Signatures, SignerKey, SignerLimits, SignerVal}, + types::{Signatures, SignerKey, SignerLimits}, PolicyClient, }; use soroban_sdk::{ @@ -9,12 +9,6 @@ use soroban_sdk::{ use crate::signer::{get_signer_val_storage, is_signer_expired, signer_expiration}; -/// Cap on nested policy-limit verification (a stored policy key inside a -/// signer's limits has its own stored limits verified recursively, which can -/// cycle: A requires B, B requires A). Depth past this bound fails closed — -/// the candidate simply doesn't cover the context. -const MAX_LIMIT_KEY_DEPTH: u32 = 4; - /// Decide whether `signer_key` (with `signer_limits`) may authorize /// `context`. /// @@ -33,7 +27,6 @@ pub fn verify_context( signer_key: &SignerKey, signer_limits: &SignerLimits, signatures: &Signatures, - depth: u32, ) -> bool { let limits = match &signer_limits.0 { // No limits: the signer can authorize anything. @@ -69,14 +62,9 @@ pub fn verify_context( match limits.get(contract.clone()) { // No entry for this contract: not permitted. None => false, - Some(required_keys) => verify_signer_limit_keys( - env, - signer_key, - signatures, - &required_keys, - context, - depth, - ), + Some(required_keys) => { + verify_signer_limit_keys(env, signer_key, signatures, &required_keys, context) + } } } // Deploy permission is not grantable through limits: CreateContract* @@ -86,24 +74,28 @@ pub fn verify_context( } } -/// Check a limits entry's co-signer requirements for one context. +/// Check a limits entry's required co-signers for one context. +/// +/// Required keys are scope-independent APPROVERS: their role as a co-signer +/// is decoupled from their own `SignerLimits`. This makes both key kinds +/// symmetric (FIX-5, audit) — a required key's own limits govern only its +/// INDEPENDENT (as-a-covering-signer) authority, never its co-signer role: /// -/// - Non-policy keys must be present in the signatures map. Their crypto, -/// existence and expiration are enforced by pass 2 of `__check_auth`, -/// which verifies every signatures map entry. +/// - Non-policy keys must be present in the signatures map. Their existence, +/// expiration and crypto are enforced by pass 2 of `__check_auth`. Their +/// own `SignerLimits` are NOT consulted here. /// - Policy keys need not be in the signatures map (they can be "adjacent" -/// requirements). If the policy key is stored on this wallet it must be -/// unexpired and its own stored limits must cover this context -/// (recursive, depth-guarded). The policy contract is then invoked via -/// `try_policy__` for this single context — a rejecting or failing policy -/// rejects the candidate, never the whole transaction. +/// requirements). The policy must APPROVE this context via `policy__`. If +/// the policy key is also stored on this wallet it must be unexpired, but +/// its own `SignerLimits` are NOT recursively enforced. Because no stored +/// policy's limits are re-entered, there is no policy-limit recursion and +/// thus no cycle to guard against. fn verify_signer_limit_keys( env: &Env, signer_key: &SignerKey, signatures: &Signatures, required_keys: &Option>, context: &Context, - depth: u32, ) -> bool { let required_keys = match required_keys { // No co-signer requirements for this contract. @@ -114,29 +106,18 @@ fn verify_signer_limit_keys( for required_key in required_keys.iter() { match &required_key { SignerKey::Policy(policy) => { - if depth >= MAX_LIMIT_KEY_DEPTH { - return false; - } - + // If the policy is stored on this wallet it must be unexpired + // (it need not be in the signatures map, so pass 2 would not + // otherwise check it). We do NOT re-enter its own limits. if let Some((signer_val, _)) = get_signer_val_storage(env, &required_key, true) { if is_signer_expired(env, signer_expiration(&signer_val)) { return false; } - - if let SignerVal::Policy(_, limits) = &signer_val { - if !verify_context( - env, - context, - &required_key, - limits, - signatures, - depth + 1, - ) { - return false; - } - } } + // The policy must approve THIS context. try_policy__ so a + // rejecting or failing policy rejects the candidate, never the + // whole transaction. if PolicyClient::new(env, policy) .try_policy__( &env.current_contract_address(), diff --git a/contracts/smart-wallet/src/lib.rs b/contracts/smart-wallet/src/lib.rs index 83cb309..aceab1a 100644 --- a/contracts/smart-wallet/src/lib.rs +++ b/contracts/smart-wallet/src/lib.rs @@ -116,12 +116,15 @@ impl SmartWalletInterface for Contract { } } - // Best-effort uninstall: a failing (or malicious) policy must never - // be able to block its own removal from the wallet. - if let SignerKey::Policy(policy) = &signer_key { - let _ = PolicyClient::new(&env, policy).try_uninstall(&env.current_contract_address()); - } - + // Removal is pure wallet state — NO untrusted policy code runs on this + // critical path (audit FIX-1). Calling the policy's `uninstall` here + // would let a malicious/broken policy block its own removal: `try_*` + // recovers only *recoverable* contract errors, so a policy that + // exhausts the transaction budget triggers a non-recoverable + // Budget/Storage ExceededLimit that unwinds the whole atomic + // transaction and rolls the removal back. Policies instead self-clean + // their install-state via the permissionless `uninstall` entrypoint, + // which verifies the signer is actually gone before acting. extend_instance(&env); SignerRemoved { @@ -193,7 +196,6 @@ impl CustomAccountInterface for Contract { &signer_key, signer_limits(&signer_val), &signatures, - 0, ) { covered = true; break; diff --git a/contracts/smart-wallet/src/storage.rs b/contracts/smart-wallet/src/storage.rs index 9b0e689..34cf30a 100644 --- a/contracts/smart-wallet/src/storage.rs +++ b/contracts/smart-wallet/src/storage.rs @@ -13,12 +13,21 @@ use soroban_sdk::Env; /// rewrite state, not any auth semantics, so drift is harmless. const WEEK_OF_LEDGERS: u32 = 60 * 60 * 24 / 5 * 7; +/// The extend threshold: one week below `max_ttl`. `saturating_sub` so a +/// hypothetical network config with `max_ttl < WEEK_OF_LEDGERS` yields a `0` +/// threshold (bump every time) rather than an overflow panic that would brick +/// all auth and mutation (audit FIX-6). `overflow-checks = true` is on for +/// release, so this must not underflow. +fn extend_threshold(max_ttl: u32) -> u32 { + max_ttl.saturating_sub(WEEK_OF_LEDGERS) +} + pub fn extend_instance(env: &Env) { let max_ttl = env.storage().max_ttl(); env.storage() .instance() - .extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl); + .extend_ttl(extend_threshold(max_ttl), max_ttl); } pub fn extend_signer_key(env: &Env, signer_key: &SignerKey, persistent: bool) { @@ -27,13 +36,13 @@ pub fn extend_signer_key(env: &Env, signer_key: &SignerKey, persistent: bool) { if persistent { env.storage().persistent().extend_ttl::( signer_key, - max_ttl - WEEK_OF_LEDGERS, + extend_threshold(max_ttl), max_ttl, ); } else { env.storage().temporary().extend_ttl::( signer_key, - max_ttl - WEEK_OF_LEDGERS, + extend_threshold(max_ttl), max_ttl, ); } diff --git a/contracts/smart-wallet/src/tests/test_admin.rs b/contracts/smart-wallet/src/tests/test_admin.rs index f57cd04..124ce71 100644 --- a/contracts/smart-wallet/src/tests/test_admin.rs +++ b/contracts/smart-wallet/src/tests/test_admin.rs @@ -7,7 +7,7 @@ extern crate std; use smart_wallet_interface::{ events::{SignerAdded, SignerRemoved, SignerUpdated, Upgraded}, types::{Error, Signer, SignerExpiration, SignerKey, SignerLimits, SignerStorage, SignerVal}, - PolicyInterface, + PolicyInterface, SmartWalletClient, }; use soroban_sdk::{ auth::Context, @@ -45,7 +45,13 @@ impl PolicyInterface for LifecyclePolicy { } fn uninstall(env: Env, wallet: Address) { - wallet.require_auth(); + // Permissionless self-clean (FIX-1): only clear once this policy is + // genuinely no longer a signer on `wallet`. + let still_signer = SmartWalletClient::new(&env, &wallet) + .get_signer(&SignerKey::Policy(env.current_contract_address())) + .is_some(); + assert!(!still_signer, "policy still installed"); + env.storage() .persistent() .remove(&LifecycleKey::Installed(wallet)); @@ -67,12 +73,15 @@ impl PolicyInterface for RejectingPolicy { fn uninstall(_env: Env, _wallet: Address) {} } -/// Policy whose uninstall always panics: removal must still succeed. +/// Policy whose `uninstall` exhausts the transaction budget — a +/// NON-recoverable failure that `try_*` cannot catch. The wallet must never +/// invoke it on the removal critical path (FIX-1), so removal must still +/// succeed. #[contract] -pub struct StickyPolicy; +pub struct BudgetBurnPolicy; #[contractimpl] -impl PolicyInterface for StickyPolicy { +impl PolicyInterface for BudgetBurnPolicy { fn policy__(_env: Env, _source: Address, _signer: SignerKey, _contexts: Vec) {} fn install(env: Env, wallet: Address) { @@ -80,8 +89,14 @@ impl PolicyInterface for StickyPolicy { let _ = env; } - fn uninstall(_env: Env, _wallet: Address) { - panic!("uninstall rejected"); + fn uninstall(env: Env, _wallet: Address) { + // Unbounded metered work → Budget ExceededLimit (non-recoverable), + // which unwinds the whole atomic transaction if ever reached. + let mut bytes = Bytes::new(&env); + loop { + bytes.push_back(0xff); + let _ = env.crypto().sha256(&bytes); + } } } @@ -554,6 +569,7 @@ fn policy_install_uninstall_lifecycle() { let env = test_env(); let a = Ed25519Signer::new(1); let policy = env.register(LifecyclePolicy, ()); + let policy_client = LifecyclePolicyClient::new(&env, &policy); let policy_key = SignerKey::Policy(policy.clone()); let (wallet, client) = register_wallet( @@ -576,9 +592,20 @@ fn policy_install_uninstall_lifecycle() { )); assert!(is_installed(&env, &policy, &wallet)); + // Permissionless uninstall is REFUSED while the policy is still a signer: + // a griefer cannot clear install-state for an active policy. + assert!(policy_client.try_uninstall(&wallet).is_err()); + assert!(is_installed(&env, &policy, &wallet)); + + // Removal is pure wallet state — the wallet does NOT call uninstall, so + // the signer is gone but the policy's install marker lingers. client.mock_all_auths().remove_signer(&policy_key); - assert!(!is_installed(&env, &policy, &wallet)); assert_eq!(client.get_signer(&policy_key), None); + assert!(is_installed(&env, &policy, &wallet)); + + // Now anyone can clean up: the cross-check to get_signer returns None. + policy_client.uninstall(&wallet); + assert!(!is_installed(&env, &policy, &wallet)); } /// A policy signer can also be the FIRST signer: the constructor runs the @@ -638,12 +665,17 @@ fn rejecting_policy_cannot_be_added() { )); } -/// A policy whose uninstall panics must NOT be able to block its own removal. +/// FIX-1 regression: a policy whose `uninstall` exhausts the transaction +/// budget (a NON-recoverable failure that `try_uninstall` could not catch) +/// must NOT be able to block its own removal. Because the wallet never calls +/// uninstall on the removal path, the budget-burn never runs and removal +/// succeeds. Under the old best-effort `try_uninstall` this call would have +/// aborted the whole transaction and rolled the removal back. #[test] -fn sticky_policy_can_still_be_removed() { +fn malicious_uninstall_cannot_block_removal() { let env = test_env(); let a = Ed25519Signer::new(1); - let policy = env.register(StickyPolicy, ()); + let policy = env.register(BudgetBurnPolicy, ()); let policy_key = SignerKey::Policy(policy.clone()); let (_, client) = register_wallet( diff --git a/contracts/smart-wallet/src/tests/test_auth.rs b/contracts/smart-wallet/src/tests/test_auth.rs index 7723362..74b9e9e 100644 --- a/contracts/smart-wallet/src/tests/test_auth.rs +++ b/contracts/smart-wallet/src/tests/test_auth.rs @@ -1,7 +1,7 @@ #![cfg(test)] //! `__check_auth` semantics: signer limits branches, expiration boundaries, //! multi-sig requirements, self-removal, deploy permission, policy -//! granularity, and the recursion depth guard. +//! granularity, and required-co-signer scope-independence (FIX-5). extern crate std; @@ -970,12 +970,76 @@ fn policy_as_limit_called_per_context() { assert_eq!(policy_count(&env, &policy), 2); } -// --- Recursion depth guard (F10) --------------------------------------------- +// --- Required co-signer scope-independence (FIX-5) --------------------------- -/// Two stored policies whose limits require each other would recurse -/// unboundedly without the depth guard; with it, coverage fails closed. +/// A non-policy required co-signer's OWN limits do not constrain its +/// co-signer role: even `Some(empty)` ("no independent permissions") does not +/// disable it as an approver. A+B authorize the token context although B could +/// not cover anything on its own. #[test] -fn cyclic_policy_limits_fail_closed() { +fn nonpolicy_co_signer_own_limits_not_enforced() { + let env = test_env(); + let a = Ed25519Signer::new(1); + let b = Ed25519Signer::new(2); + let token = Address::generate(&env); + + let (wallet, client) = register_wallet( + &env, + &a.signer( + &env, + SignerExpiration(None), + contract_limits(&env, &token, Some(vec![&env, b.signer_key(&env)])), + SignerStorage::Persistent, + ), + ); + + // B has an empty limits map — it cannot INDEPENDENTLY cover anything. + client.mock_all_auths().add_signer(&b.signer( + &env, + SignerExpiration(None), + empty_limits(&env), + SignerStorage::Persistent, + )); + + let payload = payload(&env, 7); + let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; + + // B on its own cannot cover the token context. + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![&env, (b.signer_key(&env), b.sign(&env, &payload))]), + &contexts, + ), + Err(Ok(Error::MissingContext)) + ); + + // But B still serves as A's required co-signer. + assert_eq!( + check_auth( + &env, + &wallet, + &payload, + Signatures(map![ + &env, + (a.signer_key(&env), a.sign(&env, &payload)), + (b.signer_key(&env), b.sign(&env, &payload)), + ]), + &contexts, + ), + Ok(()) + ); +} + +/// A stored policy required co-signer's OWN limits are likewise not +/// recursively enforced: only its `policy__` decision matters. What was +/// previously a "cycle" (policy_a requires policy_b requires policy_a) simply +/// resolves through each policy's `policy__` approval — no recursion, no depth +/// guard, no blowup. +#[test] +fn policy_co_signer_own_limits_not_enforced() { let env = test_env(); let policy_a = env.register(CountingPolicy, ()); let policy_b = env.register(CountingPolicy, ()); @@ -996,7 +1060,8 @@ fn cyclic_policy_limits_fail_closed() { ), ); - // policy_a requires policy_b for token, policy_b requires policy_a: cycle. + // policy_a's own limits reference policy_b (and vice versa). These are NOT + // re-entered; policy_a is used purely as an approver via policy__. client.mock_all_auths().add_signer(&Signer::Policy( policy_a.clone(), SignerExpiration(None), @@ -1021,7 +1086,8 @@ fn cyclic_policy_limits_fail_closed() { let payload = payload(&env, 7); let contexts = vec![&env, transfer_context(&env, &token, &wallet, 1)]; - // Fails closed via the depth guard — no stack/budget blowup. + // policy_a approves (CountingPolicy allows everything); its own limits are + // ignored, so this resolves to Ok with policy_a invoked exactly once. assert_eq!( check_auth( &env, @@ -1030,8 +1096,11 @@ fn cyclic_policy_limits_fail_closed() { Signatures(map![&env, (s.signer_key(&env), s.sign(&env, &payload))]), &contexts, ), - Err(Ok(Error::MissingContext)) + Ok(()) ); + assert_eq!(policy_count(&env, &policy_a), 1); + // policy_b is never invoked — policy_a's limits are not re-entered. + assert_eq!(policy_count(&env, &policy_b), 0); } /// An expired stored policy referenced in limits rejects the candidate diff --git a/contracts/smart-wallet/src/tests/test_integration.rs b/contracts/smart-wallet/src/tests/test_integration.rs index 2cca137..18a75bf 100644 --- a/contracts/smart-wallet/src/tests/test_integration.rs +++ b/contracts/smart-wallet/src/tests/test_integration.rs @@ -7,7 +7,7 @@ use std::println; extern crate std; use example_contract::{Contract as ExampleContract, ContractClient as ExampleContractClient}; -use sample_policy::Contract as PolicyContract; +use sample_policy::{Contract as PolicyContract, ContractClient as PolicyContractClient}; use smart_wallet_interface::types::{ Signatures, Signer, SignerExpiration, SignerKey, SignerLimits, SignerStorage, }; @@ -224,3 +224,54 @@ fn secp256r1_passkey_full_stack() { assert_eq!(sac_client.balance(&wallet_address), 100_000_000 - amount); assert_eq!(sac_client.balance(&recipient_address), amount); } + +/// FIX-2: `policy__` is publicly callable, but the hardened sample policy +/// authenticates its caller with `source.require_auth()`. An external caller +/// that is not the wallet — even for a wallet that DID install the policy — +/// cannot invoke `policy__` on that wallet's behalf. +/// +/// The positive direction (invoker auth satisfies `require_auth` during a real +/// `__check_auth`) is proven by `ed25519_with_policy_limits_full_stack`, which +/// drives the sample policy through `set_auths`. +#[test] +fn sample_policy_rejects_spoofed_caller() { + let env = test_env(); + let signer = Ed25519Signer::new(41); + + let (wallet_address, wallet_client) = register_wallet( + &env, + &signer.signer( + &env, + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Persistent, + ), + ); + + let policy_address = env.register(PolicyContract, ()); + let policy_client = PolicyContractClient::new(&env, &policy_address); + + // Install the policy on the wallet (wallet-authenticated). + wallet_client.mock_all_auths().add_signer(&Signer::Policy( + policy_address.clone(), + SignerExpiration(None), + SignerLimits(None), + SignerStorage::Temporary, + )); + + let token = Address::generate(&env); + let contexts = soroban_sdk::vec![&env, transfer_context(&env, &token, &wallet_address, 1)]; + + // Direct policy__ call with NO auth for the wallet: source.require_auth() + // is unsatisfied, so it fails. (Note the absence of mock_all_auths.) + let result = policy_client.try_policy__( + &wallet_address, + &SignerKey::Policy(policy_address.clone()), + &contexts, + ); + + assert!( + result.is_err(), + "spoofed policy__ caller must be rejected by source.require_auth()" + ); +} From 128169ae04da6e117f5c1a303125b674055da1d7 Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 22:30:41 -0400 Subject: [PATCH 15/30] C1 services: ground-up zephyr indexer rewrite (v1 events, single soroban-sdk) Full rewrite of the Mercury/Zephyr indexer against the v1 smart wallet and the current (P27) zephyr-sdk. Kills audit findings F1/F3/F4/F5/F7. - F1 (build): drop the smart-wallet-interface dependency (soroban-sdk 27) that created a dual-soroban-sdk tree; decode events via local #[contracttype] mirror types on a SINGLE soroban-sdk, pinned =22.0.7 to match zephyr-sdk and to keep its wasm panic-handler gated off (22.0.8+ duplicates std's panic_impl and fails to build). zephyr-sdk pinned to stellar-main-2 rev 3b83284; rustc pinned 1.93.0. Builds green for wasm32-unknown-unknown, no warnings. - New event schema: consumes signer_added/signer_updated/signer_removed/ upgraded #[contractevent] structs (map data_format) instead of sw_v1 tuples. - F3 (security): WASM-hash allowlist on ingestion. Only wallets whose on-chain instance executable is a known passkey-kit hash (from docs/deployments-testnet-2026-07-11.md) are trusted; a `wallets` table holds the trusted set and latest hash, also refreshed from `upgraded` events. - F4/F5 (expiration): UNIX-timestamp inclusive semantics; both read functions return signers with an `expired` flag instead of filtering inconsistently. - F7: native `bool` active column; explicit val nullability (Void vs Bytes(65)); documented base64-XDR limits encoding. - Drops the unauthenticated debug_signers dump. - README documents the toolchain/F1 story, schemas, function contracts, and the Mercury deploy-tooling status: published mercury-cli 0.2.1 targets removed endpoints (404); current backend moved under /rest (audit F2). Testnet deploy is pending correct tooling and is an orchestrator-gated step. Co-Authored-By: Claude Opus 4.8 --- zephyr/Cargo.lock | 1112 ++++++++++++++++++------------------ zephyr/Cargo.toml | 28 +- zephyr/README.md | 222 +++++++ zephyr/rust-toolchain.toml | 8 +- zephyr/src/lib.rs | 546 ++++++++++++------ zephyr/src/types.rs | 107 +++- zephyr/src/wallet.rs | 130 +++++ zephyr/zephyr.toml | 23 +- 8 files changed, 1404 insertions(+), 772 deletions(-) create mode 100644 zephyr/README.md create mode 100644 zephyr/src/wallet.rs diff --git a/zephyr/Cargo.lock b/zephyr/Cargo.lock index af0f773..ae9224c 100644 --- a/zephyr/Cargo.lock +++ b/zephyr/Cargo.lock @@ -4,22 +4,16 @@ version = 4 [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "once_cell", "version_check", "zerocopy", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -31,9 +25,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.94" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "ark-bls12-381" @@ -75,7 +69,7 @@ dependencies = [ "ark-serialize", "ark-std", "derivative", - "digest", + "digest 0.10.7", "itertools", "num-bigint", "num-traits", @@ -128,7 +122,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ "ark-serialize-derive", "ark-std", - "digest", + "digest 0.10.7", "num-bigint", ] @@ -155,9 +149,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base16ct" @@ -185,9 +179,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.6.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bincode" @@ -208,22 +202,34 @@ dependencies = [ ] [[package]] -name = "bumpalo" -version = "3.16.0" +name = "block-buffer" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] [[package]] -name = "byteorder" -version = "1.5.0" +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.9.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytes-lit" @@ -234,15 +240,16 @@ dependencies = [ "num-bigint", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] name = "cc" -version = "1.2.4" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -254,9 +261,9 @@ checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_eval" @@ -266,7 +273,7 @@ checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] @@ -282,15 +289,14 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "android-tzdata", "iana-time-zone", "num-traits", "serde", - "windows-targets", + "windows-link", ] [[package]] @@ -307,9 +313,18 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] @@ -332,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -347,17 +362,44 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "cfg-if 1.0.0", - "cpufeatures", + "cfg-if 1.0.4", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", - "fiat-crypto", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", "rustc_version", "subtle", "zeroize", @@ -371,55 +413,89 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] name = "darling" -version = "0.20.10" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] name = "darling_core" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", ] [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", - "syn 2.0.104", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.118", ] [[package]] name = "data-encoding" -version = "2.6.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", "zeroize", @@ -427,12 +503,11 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -452,12 +527,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -466,9 +551,9 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" @@ -477,10 +562,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", + "signature 2.2.0", ] [[package]] @@ -490,29 +575,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", - "signature", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "signature 3.0.0", ] [[package]] name = "ed25519-dalek" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "serde", - "sha2", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature 3.0.0", "subtle", "zeroize", ] [[package]] name = "either" -version = "1.13.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -522,11 +630,11 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -534,9 +642,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "escape-bytes" @@ -546,17 +654,17 @@ checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" [[package]] name = "ethnum" -version = "1.5.0" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90ca2580b73ab6a1f724b76ca11ab632df820fd6040c336200d2c1df7b3c82c" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "ff" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -566,17 +674,53 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -585,11 +729,11 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "js-sys", "libc", "wasi", @@ -603,7 +747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -618,7 +762,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -638,15 +782,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" - -[[package]] -name = "heck" -version = "0.5.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hex" @@ -669,30 +807,39 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] name = "http" -version = "1.2.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", - "fnv", "itoa", ] +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", "windows-core", ] @@ -725,13 +872,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.17.1", "serde", + "serde_core", ] [[package]] @@ -751,17 +899,18 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.76" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if 1.0.4", + "futures-util", "wasm-bindgen", ] @@ -771,55 +920,44 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "ecdsa", "elliptic-curve", - "sha2", + "sha2 0.10.9", ] [[package]] name = "keccak" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] name = "libc" -version = "0.2.168" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" -version = "0.2.11" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "log" -version = "0.4.22" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" - -[[package]] -name = "macro-string" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memory_units" @@ -829,9 +967,9 @@ checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -839,9 +977,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -851,7 +989,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] @@ -874,9 +1012,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.2" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "p256" @@ -887,7 +1025,19 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", +] + +[[package]] +name = "passkey-kit-indexer" +version = "1.0.0" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "soroban-sdk", + "stellar-strkey 0.0.13", + "zephyr-sdk", ] [[package]] @@ -898,20 +1048,19 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pest" -version = "2.7.15" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" dependencies = [ "memchr", - "thiserror 2.0.8", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.7.15" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" dependencies = [ "pest", "pest_generator", @@ -919,28 +1068,32 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.15" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] name = "pest_meta" -version = "2.7.15" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" dependencies = [ - "once_cell", "pest", - "sha2", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkcs8" version = "0.10.2" @@ -959,21 +1112,21 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "prettyplease" -version = "0.2.25" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] @@ -987,31 +1140,31 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.92" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.37" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "rand" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1021,7 +1174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1033,24 +1186,30 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ref-cast" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] @@ -1066,13 +1225,13 @@ dependencies = [ [[package]] name = "rs-zephyr-common" version = "0.1.8" -source = "git+https://github.com/xycloo/rs-zephyr-toolkit?rev=7e9e458#7e9e4580cb5918db9f088126c89e496c09b6c649" +source = "git+https://github.com/xycloo/rs-zephyr-toolkit?rev=3b832840b25289af243326fc3be8a29108891ec2#3b832840b25289af243326fc3be8a29108891ec2" dependencies = [ "anyhow", "http", "serde", - "stellar-xdr 22.1.0", - "thiserror 1.0.69", + "stellar-xdr 27.0.0", + "thiserror", ] [[package]] @@ -1085,10 +1244,10 @@ dependencies = [ ] [[package]] -name = "ryu" -version = "1.0.18" +name = "rustversion" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "schemars" @@ -1115,9 +1274,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.4" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -1140,58 +1299,69 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.24" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.216" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.216" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.133" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_with" -version = "3.14.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.7.0", + "indexmap 2.14.0", "schemars 0.8.22", "schemars 0.9.0", - "schemars 1.0.4", - "serde", - "serde_derive", + "schemars 1.2.1", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -1199,14 +1369,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] @@ -1215,26 +1385,37 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if 1.0.0", - "cpufeatures", - "digest", + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] name = "sha3" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ - "digest", + "digest 0.10.7", "keccak", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signature" @@ -1242,58 +1423,41 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", - "rand_core", + "digest 0.10.7", + "rand_core 0.6.4", ] [[package]] -name = "smallvec" -version = "1.13.2" +name = "signature" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" - -[[package]] -name = "smart-wallet-interface" -version = "0.5.0" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "soroban-sdk 23.0.2", + "rand_core 0.10.1", ] [[package]] -name = "smart-wallets-next-dima" -version = "0.0.0" -dependencies = [ - "base64 0.22.1", - "hex", - "serde", - "serde_json", - "smart-wallet-interface", - "stellar-strkey 0.0.13", - "zephyr-sdk", -] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "soroban-builtin-sdk-macros" -version = "22.1.3" +name = "smallvec" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f" -dependencies = [ - "itertools", - "proc-macro2", - "quote", - "syn 2.0.104", -] +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "soroban-builtin-sdk-macros" -version = "23.0.1" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9336adeabcd6f636a4e0889c8baf494658ef5a3c4e7e227569acd2ce9091e85" +checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f" dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] @@ -1307,47 +1471,20 @@ dependencies = [ "num-derive", "num-traits", "serde", - "soroban-env-macros 22.1.3", + "soroban-env-macros", "soroban-wasmi", "static_assertions", "stellar-xdr 22.1.0", "wasmparser", ] -[[package]] -name = "soroban-env-common" -version = "23.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00067f52e8bbf1abf0de03fe3e2fbb06910893cfbe9a7d9093d6425658833ff3" -dependencies = [ - "crate-git-revision", - "ethnum", - "num-derive", - "num-traits", - "soroban-env-macros 23.0.1", - "soroban-wasmi", - "static_assertions", - "stellar-xdr 23.0.0", - "wasmparser", -] - [[package]] name = "soroban-env-guest" version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a07dda1ae5220d975979b19ad4fd56bc86ec7ec1b4b25bc1c5d403f934e592e" dependencies = [ - "soroban-env-common 22.1.3", - "static_assertions", -] - -[[package]] -name = "soroban-env-guest" -version = "23.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd1e40963517b10963a8e404348d3fe6caf9c278ac47a6effd48771297374d6" -dependencies = [ - "soroban-env-common 23.0.1", + "soroban-env-common", "static_assertions", ] @@ -1361,9 +1498,9 @@ dependencies = [ "ark-ec", "ark-ff", "ark-serialize", - "curve25519-dalek", + "curve25519-dalek 5.0.0", "ecdsa", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "elliptic-curve", "generic-array", "getrandom", @@ -1377,52 +1514,16 @@ dependencies = [ "rand", "rand_chacha", "sec1", - "sha2", + "sha2 0.10.9", "sha3", - "soroban-builtin-sdk-macros 22.1.3", - "soroban-env-common 22.1.3", + "soroban-builtin-sdk-macros", + "soroban-env-common", "soroban-wasmi", "static_assertions", "stellar-strkey 0.0.9", "wasmparser", ] -[[package]] -name = "soroban-env-host" -version = "23.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9766c5ad78e9d8ae10afbc076301f7d610c16407a1ebb230766dbe007a48725" -dependencies = [ - "ark-bls12-381", - "ark-ec", - "ark-ff", - "ark-serialize", - "curve25519-dalek", - "ecdsa", - "ed25519-dalek", - "elliptic-curve", - "generic-array", - "getrandom", - "hex-literal", - "hmac", - "k256", - "num-derive", - "num-integer", - "num-traits", - "p256", - "rand", - "rand_chacha", - "sec1", - "sha2", - "sha3", - "soroban-builtin-sdk-macros 23.0.1", - "soroban-env-common 23.0.1", - "soroban-wasmi", - "static_assertions", - "stellar-strkey 0.0.13", - "wasmparser", -] - [[package]] name = "soroban-env-macros" version = "22.1.3" @@ -1435,36 +1536,21 @@ dependencies = [ "serde", "serde_json", "stellar-xdr 22.1.0", - "syn 2.0.104", -] - -[[package]] -name = "soroban-env-macros" -version = "23.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e6a1c5844257ce96f5f54ef976035d5bd0ee6edefaf9f5e0bcb8ea4b34228c" -dependencies = [ - "itertools", - "proc-macro2", - "quote", - "serde", - "serde_json", - "stellar-xdr 23.0.0", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] name = "soroban-ledger-snapshot" -version = "22.0.7" +version = "22.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bbe59497cb50e81861187e6bd2a2c805df253573d44ed56e7d373f79530758" +checksum = "c30035cf1e8f02f65de3e594b6da113ecdaf1cd134d8480961d62568bb15adaf" dependencies = [ "serde", "serde_json", "serde_with", - "soroban-env-common 22.1.3", - "soroban-env-host 22.1.3", - "thiserror 1.0.69", + "soroban-env-common", + "soroban-env-host", + "thiserror", ] [[package]] @@ -1478,125 +1564,59 @@ dependencies = [ "rustc_version", "serde", "serde_json", - "soroban-env-guest 22.1.3", - "soroban-env-host 22.1.3", + "soroban-env-guest", + "soroban-env-host", "soroban-ledger-snapshot", - "soroban-sdk-macros 22.0.7", + "soroban-sdk-macros", "stellar-strkey 0.0.9", ] -[[package]] -name = "soroban-sdk" -version = "23.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af0e5bf6702f5952d78c5b2bcd05b0349f9a570cc62028d90dac3710b40cbe65" -dependencies = [ - "bytes-lit", - "crate-git-revision", - "rand", - "rustc_version", - "serde", - "serde_json", - "soroban-env-guest 23.0.1", - "soroban-env-host 23.0.1", - "soroban-sdk-macros 23.0.2", - "stellar-strkey 0.0.13", -] - [[package]] name = "soroban-sdk-macros" -version = "22.0.7" +version = "22.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a141230aa65006d4b3eeee9d0589172d734a2abfbe15b84670e38e76e200b370" +checksum = "42b205cd86b34d530db87667bd287fbb194166d79b368227fd842110a914fde8" dependencies = [ "crate-git-revision", - "darling", + "darling 0.20.11", "itertools", "proc-macro2", "quote", "rustc_version", - "sha2", - "soroban-env-common 22.1.3", - "soroban-spec 22.0.7", - "soroban-spec-rust 22.0.7", + "sha2 0.10.9", + "soroban-env-common", + "soroban-spec", + "soroban-spec-rust", "stellar-xdr 22.1.0", - "syn 2.0.104", -] - -[[package]] -name = "soroban-sdk-macros" -version = "23.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b38abe20199c5d9fbff232381aa4e8e83302b34e82e38fbb090f41f1284fc920" -dependencies = [ - "darling", - "heck", - "itertools", - "macro-string", - "proc-macro2", - "quote", - "sha2", - "soroban-env-common 23.0.1", - "soroban-spec 23.0.2", - "soroban-spec-rust 23.0.2", - "stellar-xdr 23.0.0", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] name = "soroban-spec" -version = "22.0.7" +version = "22.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b54326e9516b33be99c701b37242b27efb8e66cc1f1eff994b9d9a013a4be136" +checksum = "cb6a16f2de28852c759f4da5f28cda54ec0d8dfa4c0e6e8cb3495234a72b0cea" dependencies = [ "base64 0.13.1", "stellar-xdr 22.1.0", - "thiserror 1.0.69", - "wasmparser", -] - -[[package]] -name = "soroban-spec" -version = "23.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72526d30f8825b859afa7e0b94549dad05c58a6c928b0763620412744512d7e2" -dependencies = [ - "base64 0.22.1", - "stellar-xdr 23.0.0", - "thiserror 1.0.69", + "thiserror", "wasmparser", ] [[package]] name = "soroban-spec-rust" -version = "22.0.7" +version = "22.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f009cab4dfd653bc94a06c5022f1ca9d30e198b0e451f84cf307231563d11de2" +checksum = "cdc6db5902ab21290dddf63fec4ee95703fe59891a947646e7b8607536f043fc" dependencies = [ "prettyplease", "proc-macro2", "quote", - "sha2", - "soroban-spec 22.0.7", + "sha2 0.10.9", + "soroban-spec", "stellar-xdr 22.1.0", - "syn 2.0.104", - "thiserror 1.0.69", -] - -[[package]] -name = "soroban-spec-rust" -version = "23.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9088cb8307dad026cda494971c4f13c76f9427ab26becb7cd691da95dc5e9b1d" -dependencies = [ - "prettyplease", - "proc-macro2", - "quote", - "sha2", - "soroban-spec 23.0.2", - "stellar-xdr 23.0.0", - "syn 2.0.104", - "thiserror 1.0.69", + "syn 2.0.118", + "thiserror", ] [[package]] @@ -1642,7 +1662,7 @@ checksum = "12d2bf45e114117ea91d820a846fd1afbe3ba7d717988fee094ce8227a3bf8bd" dependencies = [ "base32", "crate-git-revision", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -1653,7 +1673,7 @@ checksum = "5e3aa3ed00e70082cb43febc1c2afa5056b9bb3e348bbb43d0cd0aa88a611144" dependencies = [ "crate-git-revision", "data-encoding", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -1683,9 +1703,9 @@ dependencies = [ [[package]] name = "stellar-xdr" -version = "23.0.0" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d2848e1694b0c8db81fd812bfab5ea71ee28073e09ccc45620ef3cf7a75a9b" +checksum = "05ff843326969bdf1ef673dcdba94c08f4a3c8f1e58d6e6ef39b1bd4f749179a" dependencies = [ "base64 0.22.1", "cfg_eval", @@ -1695,7 +1715,7 @@ dependencies = [ "hex", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "stellar-strkey 0.0.13", ] @@ -1724,9 +1744,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -1739,16 +1759,7 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a" -dependencies = [ - "thiserror-impl 2.0.8", + "thiserror-impl", ] [[package]] @@ -1759,56 +1770,59 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] name = "time" -version = "0.3.37" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.19" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" -version = "1.17.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -1818,9 +1832,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" -version = "1.0.14" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "version_check" @@ -1830,40 +1844,28 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.99" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ - "cfg-if 1.0.0", + "cfg-if 1.0.4", "once_cell", + "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.104", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.99" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1871,22 +1873,25 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.99" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.104", - "wasm-bindgen-backend", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.99" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] [[package]] name = "wasmi_arena" @@ -1912,7 +1917,7 @@ version = "0.116.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" dependencies = [ - "indexmap 2.7.0", + "indexmap 2.14.0", "semver", ] @@ -1961,76 +1966,62 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.52.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-targets", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "windows-targets" -version = "0.52.6" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] [[package]] name = "zephyr-macros" @@ -2039,66 +2030,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79b82b4e5e981bb7345e0ae5e55230e21323df21539ac9e11aa9efe8cca0bbd9" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] name = "zephyr-sdk" version = "0.2.5" -source = "git+https://github.com/xycloo/rs-zephyr-toolkit?rev=7e9e458#7e9e4580cb5918db9f088126c89e496c09b6c649" +source = "git+https://github.com/xycloo/rs-zephyr-toolkit?rev=3b832840b25289af243326fc3be8a29108891ec2#3b832840b25289af243326fc3be8a29108891ec2" dependencies = [ "bincode", "charming-fork-zephyr", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "rs-zephyr-common", "serde", "serde_json", - "sha2", - "soroban-sdk 22.0.7", + "sha2 0.10.9", + "soroban-sdk", "stellar-strkey 0.0.8", "stellar-xdr 22.1.0", - "thiserror 1.0.69", + "thiserror", "wee_alloc", "zephyr-macros", ] [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ - "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.118", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/zephyr/Cargo.toml b/zephyr/Cargo.toml index ea06232..a30849b 100644 --- a/zephyr/Cargo.toml +++ b/zephyr/Cargo.toml @@ -1,19 +1,35 @@ [package] -name = "smart-wallets-next-dima" -version = "0.0.0" +name = "passkey-kit-indexer" +version = "1.0.0" edition = "2021" +publish = false [lib] crate-type = ["cdylib"] [dependencies] -zephyr-sdk = { version = "0.2.4", git="https://github.com/xycloo/rs-zephyr-toolkit", rev="7e9e458" } +# Current (Protocol 27) Zephyr SDK. The `stellar-main-2` branch is upstream's +# live default branch; it pins a single soroban-sdk (22.x) internally, which is +# the ONLY soroban-sdk in this program's tree (see README "Toolchain & F1"). +zephyr-sdk = { git = "https://github.com/xycloo/rs-zephyr-toolkit", rev = "3b832840b25289af243326fc3be8a29108891ec2" } + +# Pinned to EXACTLY the soroban-sdk that zephyr-sdk resolves, so Cargo unifies +# to a single crate instance (this is the ONLY soroban-sdk in the tree — we do +# NOT depend on `smart-wallet-interface`/soroban-sdk 27, which caused the +# dual-soroban-sdk build failure, audit F1). Used only for the local +# `#[contracttype]` mirror types that decode the wallet's ScVal events/keys. +# +# `=22.0.7` is required: 22.0.7 gates its wasm `#[panic_handler]` behind +# `not(feature = "alloc")` (and zephyr-sdk enables `alloc`), so it emits no +# panic handler and doesn't clash with `std` (which stellar-xdr pulls in). 22.0.8+ +# made the panic handler unconditional on wasm, which duplicates `std`'s +# `panic_impl` lang item and fails the build. See zephyr/README "Toolchain & F1". +soroban-sdk = { version = "=22.0.7", features = ["alloc"] } + stellar-strkey = "0.0.13" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -smart-wallet-interface = { path = "../contracts/smart-wallet-interface" } -hex = "0.4.3" base64 = "0.22" +hex = "0.4.3" [profile.release] opt-level = "z" diff --git a/zephyr/README.md b/zephyr/README.md new file mode 100644 index 0000000..23a0e7d --- /dev/null +++ b/zephyr/README.md @@ -0,0 +1,222 @@ +# passkey-kit Mercury (Zephyr) indexer + +Mercury Zephyr program that indexes passkey-kit **v1** smart-wallet signers for +two query patterns: + +- **Enumerate a wallet's signers** — `get_signers_by_address(address)`. +- **Reverse lookup (keyId → wallets)** — `get_addresses_by_signer(key, kind)`. + +It is the `MercuryIndexer` backend behind the SDK's `SignerIndexer` +abstraction; the Stellar Indexer backend is the interchangeable second one. + +This is a ground-up rewrite (overhaul v1). It consumes the reworked contract's +`#[contractevent]` event schema, indexes on a single soroban-sdk, enforces a +WASM-hash allowlist on ingestion, and uses consistent UNIX-timestamp expiration +semantics. See "Audit fixes" below. + +--- + +## Build + +```sh +cd zephyr +cargo build --release --target wasm32-unknown-unknown +# or, equivalently, the Mercury wrapper: +mercury-cli build +``` + +Artifact: `target/wasm32-unknown-unknown/release/passkey_kit_indexer.wasm`. + +### Toolchain & the dual-soroban-sdk trap (audit F1) + +The legacy program did not build: it pulled **two** soroban-sdks — one from the +old `zephyr-sdk` (22.x) and one from `smart-wallet-interface` (23.x) — whose +types don't unify, plus a stale rustc pin. This rewrite fixes it structurally: + +- **No `smart-wallet-interface` dependency.** The event/signer types are + mirrored locally in `src/wallet.rs` as `#[contracttype]` definitions on THIS + crate's soroban-sdk. The ScVal enum/struct wire encoding is protocol-stable + across soroban-sdk versions, so the mirrors decode the contract's + soroban-sdk-27-emitted events byte-for-byte while keeping the indexer + decoupled from the contract crate's toolchain. +- **Exactly one soroban-sdk in the tree**, pinned `=22.0.7` to match what the + current `zephyr-sdk` resolves. `22.0.7` is required, not incidental: + - `22.0.7` gates its wasm `#[panic_handler]` behind + `cfg(all(not(feature = "alloc"), target_family = "wasm"))`. `zephyr-sdk` + enables the `alloc` feature, so soroban-sdk emits **no** panic handler. + - `22.0.8+` made the panic handler unconditional on wasm. Because the current + `zephyr-sdk` graph links `std` (via `rs-zephyr-common`'s `stellar-xdr 27`), + that duplicates `std`'s `panic_impl` lang item and the build fails with + `E0152: found duplicate lang item`. +- **`zephyr-sdk` is pinned to the `stellar-main-2` branch** (the current, P27 + default branch), rev `3b832840…`. `Cargo.toml` records the exact rev. +- **rustc** is pinned to `1.93.0` (`rust-toolchain.toml`). The old + `stable-2024-09-05` (rustc 1.81) predates `stellar-xdr 27`, which + `rs-zephyr-common` now pulls in. + +> Note on "soroban-sdk 27": the overhaul plan targeted soroban-sdk 27 here, but +> upstream `zephyr-sdk` (even on `stellar-main-2`) is still built on soroban-sdk +> 22.x — no soroban-sdk-27 `zephyr-sdk` exists yet. The plan's real intent (F1) +> was "a single soroban-sdk that builds", which this achieves. Ledger-meta XDR +> parsing is handled by `zephyr-sdk` itself; the program only decodes ScVals, +> which are wire-stable. + +--- + +## Events consumed (v1 `#[contractevent]` schema) + +The contract emits `#[contractevent]` structs (default `data_format = "map"`): +topic 0 is the snake_case struct name; `#[topic]` fields follow; the remaining +fields are an `ScVal::Map` keyed by field name. + +| Event | Topics | Data (`Map`) | Indexer action | +|---|---|---|---| +| `signer_added` | `[sym, SignerKey]` | `{ storage, val }` | upsert signer, `active=true` | +| `signer_updated` | `[sym, SignerKey]` | `{ old_storage, storage, val }` | upsert signer (rewrite in place) | +| `signer_removed` | `[sym, SignerKey]` | `{ storage }` | soft-delete (`active=false`) | +| `upgraded` | `[sym]` | `{ new_hash, old_hash }` | refresh wallet WASM hash | + +`SignerVal` carries `SignerExpiration(Option)` (UNIX seconds) and +`SignerLimits`; the secp256r1 variant also carries the 65-byte SEC-1 public key. +The mirror definitions live in `src/wallet.rs`. + +--- + +## Storage schema + +`signers` — one row per `(wallet address, signer key)`: + +| Column | Type | Notes | +|---|---|---| +| `address` | BYTEA (indexed) | wallet `ScVal::Address` XDR | +| `key` | BYTEA (indexed) | `SignerKey` ScVal XDR | +| `val` | BYTEA | secp256r1 pubkey `ScVal::Bytes(65)`, else `ScVal::Void` | +| `limits` | BYTEA | `SignerLimits` ScVal XDR | +| `exp` | BIGINT | UNIX seconds, or `i64::MAX` = never | +| `storage` | BYTEA | `SignerStorage` ScVal XDR | +| `active` | BYTEA | native `bool` (bincode) — live/removed | + +`wallets` — trusted wallet set (ingestion gate + latest known WASM hash): + +| Column | Type | Notes | +|---|---|---| +| `address` | BYTEA (indexed) | wallet `ScVal::Address` XDR | +| `wasm_hash` | BYTEA | `ScVal::Bytes(32)` of the instance executable | + +There is no row-delete host op, so removals are a soft-delete flag and expired +rows are never pruned on-chain (they are flagged, not deleted). The `wallets` +table doubles as the WASM-hash allowlist gate. + +--- + +## WASM-hash allowlist (audit F3) + +Any contract can emit well-formed `signer_added` events; without a gate an +attacker could inject rows and poison the reverse lookup. On each ledger close +the program scans **successful** contract-instance ledger entries and records a +wallet as trusted only when its instance executable hash is a known passkey-kit +WASM (`src/wallet.rs::ALLOWLISTED_WASM_HASHES`, sourced from +`docs/deployments-testnet-2026-07-11.md` — never rebuilt locally). Signer events +are ingested only from wallets in the trusted set. The SDK still verifies +ownership client-side (audit F7) as defence in depth. + +--- + +## Expiration semantics (audit F4/F5) + +`SignerExpiration` is a UNIX timestamp in seconds, **inclusive**: a signer is +valid while `ledger_timestamp <= exp` and expired once `ledger_timestamp > exp`. +Both read functions apply the identical rule and **return** expired signers with +an `expired` flag rather than silently filtering them (the old code filtered +expiration in one function and ignored it in the other, using an off-by-one +ledger-sequence comparison). Removed signers are omitted (soft-deleted); surface +removals via the Stellar Indexer tombstone backend. + +The unauthenticated `debug_signers` full-table dump was removed. + +--- + +## Function contracts + +Invoke via `POST {mercuryBackend}/zephyr/execute` with the function name and a +JSON body. Responses are JSON. + +### `get_signers_by_address` + +Request: `{ "address": "C..." }` + +Response: `SignerResponse[]` + +```jsonc +{ + "kind": "Policy" | "Ed25519" | "Secp256r1", + "key": "C… | G… | base64url(keyId)", // by kind + "val": "base64url(65-byte pubkey)" | null, // secp256r1 only + "expiration": 1752192000 | null, // UNIX seconds; null = never + "expired": false, // now > expiration + "storage": "Persistent" | "Temporary", + "limits": "base64(SignerLimits XDR)" +} +``` + +### `get_addresses_by_signer` + +Request: `{ "key": "…", "kind": "Policy" | "Ed25519" | "Secp256r1" }` +(`key` encoding matches the `key` field above for that kind.) + +Response: `WalletMatch[]` + +```jsonc +{ + "address": "C…", + "expiration": 1752192000 | null, + "expired": false, + "storage": "Persistent" | "Temporary" +} +``` + +The SDK's `findWallets` maps this to a plain address list; the expiration flag +is exposed for parity with `get_signers_by_address`. + +--- + +## Deploy (Mercury testnet) — tooling status (audit F2) + +**Re-verified against current upstream (2026-07): the published deploy tooling +is deprecated and does not work as documented.** + +- The published `mercury-cli` (crates.io `0.2.1`, the latest) hardcodes the base + `https://api.mercurydata.app` and POSTs to `/zephyr_table_new` and + `/zephyr_upload`. Both return **404** — upstream removed those endpoints (see + rs-zephyr-toolkit commit *"flag the legacy Zephyr CLI — its server-side + commands target removed backend endpoints; deprecate catchup"*, 2026-07-06). + Note the CLI still prints `Successfully deployed Zephyr program.` after the + 404s — its exit code and message are **not** trustworthy. +- The current testnet backend has moved under `…/rest` + (`GET https://api.mercurydata.app/rest/health` → `{"service":"up",…}`). The + newer `mercury-cli` that targets it takes a `--base …/rest` flag but is **not + published** to crates.io and is not in a public repo at the time of writing. + +**Historical command (for reference; currently 404s):** + +```sh +export MERCURY_JWT="…" # from repo-root .env: MERCURY_TESTNET_JWT — NEVER print/commit +mercury-cli --jwt "$MERCURY_JWT" --local false --mainnet false deploy +``` + +**Current deploy path:** the Mercury web dashboard (`test.mercurydata.app`, +"custom ingestion") or a `--base`-capable `mercury-cli` built from Mercury's +current source. This is an outward, account-touching step; the orchestrator +runs it at the verify/endgame gate. Tables are created from `zephyr.toml` +(program name `passkey-kit-indexer`); the wasm above is uploaded as-is. + +Secrets rule: the Mercury JWT comes from the repo-root `.env` +(`MERCURY_TESTNET_JWT` / `MERCURY_MAINNET_JWT`) via shell substitution only — +never printed, logged, or committed. + +### Data catchup / backfill + +Standard execution (new ledger closes) needs no subscription. Historical +backfill needs a contract-event subscription (via the dashboard) plus a +`mercury-cli catchup` scoped by contracts/topics; the old catchup flow was +deprecated on 2026-07-06. Backfill is optional and orthogonal to live indexing. diff --git a/zephyr/rust-toolchain.toml b/zephyr/rust-toolchain.toml index 74a50d8..0aaad0e 100644 --- a/zephyr/rust-toolchain.toml +++ b/zephyr/rust-toolchain.toml @@ -1,4 +1,8 @@ +# Modern stable toolchain that builds the current (P27) zephyr-sdk graph. The +# old pin (stable-2024-09-05 / rustc 1.81) predates stellar-xdr 27, which +# rs-zephyr-common now pulls in. `wasm32-unknown-unknown` is the Zephyr VM +# target (built with the +multivalue / stack-size flags in .cargo/config.toml). [toolchain] -channel = "stable-2024-09-05" +channel = "1.93.0" targets = ["wasm32-unknown-unknown"] -components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"] \ No newline at end of file +components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"] diff --git a/zephyr/src/lib.rs b/zephyr/src/lib.rs index 8a05564..f61f058 100644 --- a/zephyr/src/lib.rs +++ b/zephyr/src/lib.rs @@ -1,104 +1,82 @@ +//! passkey-kit Mercury (Zephyr) indexer. +//! +//! Ingests the v1 smart-wallet `#[contractevent]` events (`signer_added`, +//! `signer_updated`, `signer_removed`, `upgraded`) into two tables and exposes +//! two serverless read functions: +//! +//! - `get_signers_by_address(address)` — enumerate a wallet's signers. +//! - `get_addresses_by_signer(key, kind)` — reverse lookup (keyId -> wallets). +//! +//! Design notes (see `zephyr/README.md` for the full story): +//! - Single soroban-sdk (22.x, via zephyr-sdk); NO `smart-wallet-interface` +//! dependency — the mirror types in `wallet.rs` decode the events instead. +//! This kills the dual-soroban-sdk build failure (audit F1). +//! - WASM-hash allowlist gate on ingestion: only events from contracts whose +//! on-chain instance executable is a known passkey-kit wallet are ingested +//! (audit F3). +//! - Expiration is a UNIX timestamp, inclusive (`now <= exp`); it is stored and +//! returned with an `expired` flag rather than filtered, and BOTH read +//! functions apply the identical semantic (audit F4/F5). +//! - `debug_signers` (an unauthenticated full-table dump) is gone. + use base64::{ - engine::general_purpose::URL_SAFE, engine::general_purpose::URL_SAFE_NO_PAD, Engine as _, + engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}, + Engine as _, }; use serde::{Deserialize, Serialize}; -use smart_wallet_interface::types::{ - SignerExpiration, SignerKey, SignerLimits, SignerStorage, SignerVal, +use soroban_sdk::{ + xdr::{ + ContractExecutable, Hash, LedgerEntryData, Limits, ScAddress, ScSymbol, ScVal, WriteXdr, + }, + Address, Bytes, BytesN, }; use stellar_strkey::{ed25519, Strkey}; -use types::{ - Signers, SignersActive, SignersAddress, SignersKeyValLimitsExpStorage, - SignersValLimitsExpStorageActive, -}; use zephyr_sdk::{ - soroban_sdk::{ - self, symbol_short, - xdr::{Hash, ScAddress, ScVal, ToXdr}, - Address, Bytes, BytesN, Symbol, - }, + bincode, utils::{address_from_str, address_to_alloc_string}, EnvClient, }; mod types; +mod wallet; + +use types::{ + Signer, SignerActiveMutation, SignerKeyOnly, SignerMutation, SignerReadRow, Wallet, + WalletAddrOnly, WalletHashMutation, WalletMatchRow, EXP_NEVER, +}; +use wallet::{ + is_allowlisted, SignerAddedData, SignerKey, SignerRemovedData, SignerStorage, SignerUpdatedData, + SignerVal, UpgradedData, +}; -const SW_V1: Symbol = symbol_short!("sw_v1"); -const ADD: Symbol = symbol_short!("add"); -const UPDATE: Symbol = symbol_short!("update"); -const REMOVE: Symbol = symbol_short!("remove"); +// --------------------------------------------------------------------------- +// Ingestion +// --------------------------------------------------------------------------- #[no_mangle] pub extern "C" fn on_close() { let env = EnvClient::new(); - for event in env.reader().pretty().soroban_events() { - if let Some(topic0) = event.topics.get(0) { - if let Ok(t0) = env.try_from_scval::(topic0) { - if t0 == SW_V1 { - if let Some(topic1) = event.topics.get(1) { - if let Ok(t1) = env.try_from_scval::(topic1) { - if let Some(key) = event.topics.get(2) { - let address = - ScVal::Address(ScAddress::Contract(Hash::from(event.contract))); - - if t1 == ADD || t1 == UPDATE { - let mut older: Vec = env - .read_filter() - .column_equal_to_xdr("address", &address) - .column_equal_to_xdr("key", key) - .read() - .unwrap(); - - let (signer_val, signer_storage): (SignerVal, SignerStorage) = - env.from_scval(&event.data); - - if let Some(older) = older.get_mut(0) { - let (public_key, signer_expiration, signer_limits) = - get_signer_expiration_limits(signer_val); - - older.val = env.to_scval(public_key); - older.exp = signer_expiration.0.unwrap_or(u32::MAX); - older.limits = env.to_scval(signer_limits); - older.storage = env.to_scval(signer_storage); - older.active = ScVal::Bool(true); - - env.update() - .column_equal_to_xdr("address", &address) - .column_equal_to_xdr("key", key) - .execute(older) - .unwrap(); - } else { - let (public_key, signer_expiration, signer_limits) = - get_signer_expiration_limits(signer_val); - let signer = Signers { - address, - key: key.clone(), - val: env.to_scval(public_key), - limits: env.to_scval(signer_limits), - exp: signer_expiration.0.unwrap_or(u32::MAX), - storage: env.to_scval(signer_storage), - active: ScVal::Bool(true), - }; - - env.put(&signer); - } - } else if t1 == REMOVE { - let mut older: Vec = env - .read_filter() - .column_equal_to_xdr("address", &address) - .column_equal_to_xdr("key", key) - .read() - .unwrap(); - - if let Some(older) = older.get_mut(0) { - older.active = ScVal::Bool(false); - - env.update() - .column_equal_to_xdr("address", &address) - .column_equal_to_xdr("key", key) - .execute(older) - .unwrap(); - } + // Pass 1 — refresh the trusted-wallet set from this close's contract + // instances. A wallet is trusted iff an allowlisted smart-wallet WASM was + // observed as its instance executable (audit F3). `trusted` also serves as + // an in-memory cache so same-close events (e.g. the constructor's + // `signer_added`) are accepted before the `wallets` write is committed. + let mut trusted: Vec<[u8; 32]> = Vec::new(); + let entries = env.reader().v1_success_ledger_entries(); + for entry in entries.created.iter().chain(entries.updated.iter()) { + if let LedgerEntryData::ContractData(cd) = &entry.data { + if let ScVal::LedgerKeyContractInstance = cd.key { + if let ScVal::ContractInstance(instance) = &cd.val { + if let ContractExecutable::Wasm(Hash(hash)) = &instance.executable { + if is_allowlisted(hash) { + if let ScAddress::Contract(Hash(id)) = &cd.contract { + let address = ScVal::Address(ScAddress::Contract(Hash(*id))); + let wasm_hash = + env.to_scval(BytesN::from_array(env.soroban(), hash)); + upsert_wallet(&env, address, wasm_hash); + if !trusted.contains(id) { + trusted.push(*id); } } } @@ -107,106 +85,282 @@ pub extern "C" fn on_close() { } } } -} -fn get_signer_expiration_limits( - signer_val: SignerVal, -) -> (Option>, SignerExpiration, SignerLimits) { - match signer_val { - SignerVal::Policy(signer_expiration, signer_limits) => { - (None, signer_expiration, signer_limits) + // Pass 2 — ingest signer events from trusted wallets only. Event names are + // matched as raw `ScVal::Symbol` values (deterministic structural byte + // comparison) rather than via soroban `Symbol` equality, which for >9-char + // symbols routes through env object comparison. + let sym_added = sym_scval("signer_added"); + let sym_updated = sym_scval("signer_updated"); + let sym_removed = sym_scval("signer_removed"); + let sym_upgraded = sym_scval("upgraded"); + + for event in env.reader().pretty().soroban_events() { + let topic0 = match event.topics.get(0) { + Some(t) => t, + None => continue, + }; + if topic0 != &sym_added + && topic0 != &sym_updated + && topic0 != &sym_removed + && topic0 != &sym_upgraded + { + continue; } - SignerVal::Ed25519(signer_expiration, signer_limits) => { - (None, signer_expiration, signer_limits) + + // Trust gate: this-close instance set, or a previously-recorded wallet. + let contract_id = event.contract; + let address = ScVal::Address(ScAddress::Contract(Hash(contract_id))); + let is_trusted = trusted.contains(&contract_id) || wallet_is_trusted(&env, &address); + if !is_trusted { + continue; + } + if !trusted.contains(&contract_id) { + trusted.push(contract_id); } - SignerVal::Secp256r1(public_key, signer_expiration, signer_limits) => { - (Some(public_key), signer_expiration, signer_limits) + + if topic0 == &sym_added { + let key = match event.topics.get(1) { + Some(key) => key.clone(), + None => continue, + }; + match env.try_from_scval::(&event.data) { + Ok(data) => ingest_signer(&env, address, key, data.val, data.storage), + Err(_) => continue, + } + } else if topic0 == &sym_updated { + let key = match event.topics.get(1) { + Some(key) => key.clone(), + None => continue, + }; + // `old_storage` (the pre-update durability) is available on + // `SignerUpdatedData` but needs no separate handling: the index + // keeps one row per (wallet, key) and rewrites `storage` in place, + // so a durability flip is captured by the new `storage` alone. + match env.try_from_scval::(&event.data) { + Ok(data) => ingest_signer(&env, address, key, data.val, data.storage), + Err(_) => continue, + } + } else if topic0 == &sym_removed { + let key = match event.topics.get(1) { + Some(key) => key.clone(), + None => continue, + }; + // Decode to validate the v1 `signer_removed` data shape (Map{storage}). + if env.try_from_scval::(&event.data).is_err() { + continue; + } + deactivate_signer(&env, address, key); + } else { + // `upgraded`: refresh the (already-trusted) wallet's WASM hash. + if let Ok(data) = env.try_from_scval::(&event.data) { + refresh_wallet_hash(&env, &address, env.to_scval(data.new_hash)); + } } } } +// --- ingestion helpers ------------------------------------------------------ + +/// Build an `ScVal::Symbol` for an event-name comparison (event topic 0). +fn sym_scval(name: &str) -> ScVal { + ScVal::Symbol(ScSymbol(name.to_string().try_into().unwrap())) +} + +fn wallet_is_trusted(env: &EnvClient, address: &ScVal) -> bool { + let rows: Vec = env + .read_filter() + .column_equal_to_xdr("address", address) + .read() + .unwrap(); + !rows.is_empty() +} + +fn upsert_wallet(env: &EnvClient, address: ScVal, wasm_hash: ScVal) { + let existing: Vec = env + .read_filter() + .column_equal_to_xdr("address", &address) + .read() + .unwrap(); + if existing.is_empty() { + env.put(&Wallet { address, wasm_hash }); + } else { + env.update() + .column_equal_to_xdr("address", &address) + .execute(&WalletHashMutation { wasm_hash }) + .unwrap(); + } +} + +fn refresh_wallet_hash(env: &EnvClient, address: &ScVal, wasm_hash: ScVal) { + env.update() + .column_equal_to_xdr("address", address) + .execute(&WalletHashMutation { wasm_hash }) + .unwrap(); +} + +/// Decode a signer value into its indexable columns and upsert the row. +fn ingest_signer( + env: &EnvClient, + address: ScVal, + key: ScVal, + signer_val: SignerVal, + storage: SignerStorage, +) { + let (public_key, expiration, limits) = signer_val.into_parts(); + let val = match public_key { + Some(public_key) => env.to_scval(public_key), + None => ScVal::Void, + }; + let exp = expiration.0.map(|t| t as i64).unwrap_or(EXP_NEVER); + upsert_signer( + env, + address, + key, + val, + env.to_scval(limits), + exp, + env.to_scval(storage), + ); +} + +fn upsert_signer( + env: &EnvClient, + address: ScVal, + key: ScVal, + val: ScVal, + limits: ScVal, + exp: i64, + storage: ScVal, +) { + let existing: Vec = env + .read_filter() + .column_equal_to_xdr("address", &address) + .column_equal_to_xdr("key", &key) + .read() + .unwrap(); + if existing.is_empty() { + env.put(&Signer { + address, + key, + val, + limits, + exp, + storage, + active: true, + }); + } else { + env.update() + .column_equal_to_xdr("address", &address) + .column_equal_to_xdr("key", &key) + .execute(&SignerMutation { + val, + limits, + exp, + storage, + active: true, + }) + .unwrap(); + } +} + +fn deactivate_signer(env: &EnvClient, address: ScVal, key: ScVal) { + let existing: Vec = env + .read_filter() + .column_equal_to_xdr("address", &address) + .column_equal_to_xdr("key", &key) + .read() + .unwrap(); + if !existing.is_empty() { + env.update() + .column_equal_to_xdr("address", &address) + .column_equal_to_xdr("key", &key) + .execute(&SignerActiveMutation { active: false }) + .unwrap(); + } +} + +// --------------------------------------------------------------------------- +// Serverless read functions +// --------------------------------------------------------------------------- + #[derive(Deserialize)] pub struct SignersByAddressRequest { address: String, } #[derive(Serialize)] -pub struct SignersByAddressResponse { +pub struct SignerResponse { + /// "Policy" | "Ed25519" | "Secp256r1". kind: String, + /// Policy: C-address; Ed25519: G-address; Secp256r1: base64url(keyId). key: String, + /// Secp256r1: base64url(65-byte SEC-1 public key); null otherwise. val: Option, - expiration: Option, + /// Expiration UNIX timestamp (seconds), or null for never. + expiration: Option, + /// Whether the signer is expired at the current ledger timestamp. + expired: bool, + /// "Persistent" | "Temporary". storage: String, + /// base64 (standard) of the `SignerLimits` XDR. limits: String, } +/// Enumerate a wallet's live (non-removed) signers, expired ones included and +/// flagged. Removed signers are omitted (soft-deleted); surface removals via +/// the Stellar Indexer tombstone backend. #[no_mangle] pub extern "C" fn get_signers_by_address() { let env = EnvClient::empty(); let SignersByAddressRequest { address } = env.read_request_body(); + let now = env.soroban().ledger().timestamp(); - let address = address_from_str(&env, address.as_str()); - let address = env.to_scval(address); + let address = env.to_scval(address_from_str(&env, address.as_str())); + let active_true = bincode::serialize(&true).unwrap(); - let signers: Vec = env + let rows: Vec = env .read_filter() .column_equal_to_xdr("address", &address) - .column_equal_to_xdr("active", &ScVal::Bool(true)) - .column_gt("exp", env.soroban().ledger().sequence()) + .column_equal_to_bytes("active", &active_true) .read() .unwrap(); - let mut response: Vec = vec![]; - - for SignersKeyValLimitsExpStorage { - key, - val, - limits, - exp, - storage, - } in signers - { - let signer_key = env.from_scval::(&key); - let signer_limits = env.from_scval::(&limits); - let signer_storage = env.from_scval::(&storage); - - let (kind_parsed, key_parsed) = match signer_key { - SignerKey::Policy(policy) => ( - String::from("Policy"), - address_to_alloc_string(&env, policy), - ), - SignerKey::Ed25519(ed25519) => ( - String::from("Ed25519"), - Strkey::PublicKeyEd25519(ed25519::PublicKey(ed25519.to_array())).to_string(), + let mut response: Vec = Vec::new(); + for row in rows { + let (kind, key) = match env.from_scval::(&row.key) { + SignerKey::Policy(policy) => { + ("Policy".to_string(), address_to_alloc_string(&env, policy)) + } + SignerKey::Ed25519(public_key) => ( + "Ed25519".to_string(), + Strkey::PublicKeyEd25519(ed25519::PublicKey(public_key.to_array())).to_string(), ), - SignerKey::Secp256r1(secp256r1) => ( - String::from("Secp256r1"), - URL_SAFE_NO_PAD.encode(secp256r1.to_alloc_vec()), + SignerKey::Secp256r1(key_id) => ( + "Secp256r1".to_string(), + URL_SAFE_NO_PAD.encode(key_id.to_alloc_vec()), ), }; - let mut val_parsed: Option = None; - - if let Some(public_key) = env.from_scval::>>(&val) { - val_parsed = Some(URL_SAFE_NO_PAD.encode(public_key.to_array())); - } - - let storage_parsed = match signer_storage { - SignerStorage::Persistent => String::from("Persistent"), - SignerStorage::Temporary => String::from("Temporary"), - }; - - response.push(SignersByAddressResponse { - kind: kind_parsed, - key: key_parsed, - val: val_parsed, - expiration: if exp == u32::MAX { None } else { Some(exp) }, - storage: storage_parsed, - limits: URL_SAFE.encode(signer_limits.to_xdr(&env.soroban()).to_alloc_vec()), - }) + let val = env + .from_scval::>>(&row.val) + .map(|public_key| URL_SAFE_NO_PAD.encode(public_key.to_array())); + + let storage = storage_string(env.from_scval::(&row.storage)); + let (expiration, expired) = expiry(row.exp, now); + + response.push(SignerResponse { + kind, + key, + val, + expiration, + expired, + storage, + limits: URL_SAFE.encode(row.limits.to_xdr(Limits::none()).unwrap()), + }); } - env.conclude(response) + env.conclude(response); } #[derive(Deserialize)] @@ -215,58 +369,80 @@ pub struct AddressBySignerRequest { kind: String, } +#[derive(Serialize)] +pub struct WalletMatch { + /// Wallet contract C-address. + address: String, + expiration: Option, + expired: bool, + storage: String, +} + +/// Reverse lookup: wallets on which a given signer key is live. Matches include +/// expired signers, flagged via `expired`. The SDK's `findWallets` maps this to +/// a plain address list; the expiration flag is exposed for parity with +/// `get_signers_by_address` (audit F5). #[no_mangle] pub extern "C" fn get_addresses_by_signer() { let env = EnvClient::empty(); let AddressBySignerRequest { key, kind } = env.read_request_body(); + let now = env.soroban().ledger().timestamp(); - let key_scval: ScVal; - - if kind == "Policy" { - let key = address_from_str(&env, &key.as_str()); - key_scval = env.to_scval(SignerKey::Policy(key)); - } else if kind == "Ed25519" { - // This is pretty verbose and manual but there's no easy way to go from a G-address to it's 32 bytes of public key - let key = address_from_str(&env, &key.as_str()).to_xdr(&env.soroban()); - let key = key.slice(key.len() - 32..); - let mut slice = [0u8; 32]; - key.copy_into_slice(&mut slice); - let key = BytesN::from_array(&env.soroban(), &slice); - key_scval = env.to_scval(SignerKey::Ed25519(key)); - } else if kind == "Secp256r1" { - let key = URL_SAFE_NO_PAD.decode(key).unwrap(); - let key = Bytes::from_slice(&env.soroban(), key.as_slice()); - key_scval = env.to_scval(SignerKey::Secp256r1(key)); - } else { - panic!("Invalid signer type"); - } + let key_scval = match kind.as_str() { + "Policy" => env.to_scval(SignerKey::Policy(address_from_str(&env, key.as_str()))), + "Ed25519" => { + let raw = stellar_strkey::ed25519::PublicKey::from_string(&key).unwrap().0; + env.to_scval(SignerKey::Ed25519(BytesN::from_array(env.soroban(), &raw))) + } + "Secp256r1" => { + let raw = URL_SAFE_NO_PAD.decode(key).unwrap(); + env.to_scval(SignerKey::Secp256r1(Bytes::from_slice(env.soroban(), &raw))) + } + _ => { + env.conclude::>(Vec::new()); + return; + } + }; - let signers: Vec = env + let active_true = bincode::serialize(&true).unwrap(); + let rows: Vec = env .read_filter() .column_equal_to_xdr("key", &key_scval) - .column_equal_to_xdr("active", &ScVal::Bool(true)) + .column_equal_to_bytes("active", &active_true) .read() .unwrap(); - if signers.is_empty() { - env.conclude::>(Vec::default()); - } else { - let contracts = signers - .iter() - .map(|SignersAddress { address }| { - address_to_alloc_string(&env, env.from_scval::
    (address)) - }) - .collect::>(); - - env.conclude(contracts); + let mut response: Vec = Vec::new(); + for row in rows { + let address = address_to_alloc_string(&env, env.from_scval::
    (&row.address)); + let storage = storage_string(env.from_scval::(&row.storage)); + let (expiration, expired) = expiry(row.exp, now); + response.push(WalletMatch { + address, + expiration, + expired, + storage, + }); } + + env.conclude(response); } -#[no_mangle] -pub extern "C" fn debug_signers() { - let env = EnvClient::empty(); +// --- read helpers ----------------------------------------------------------- - let signers = env.read::(); +fn storage_string(storage: SignerStorage) -> String { + match storage { + SignerStorage::Persistent => "Persistent".to_string(), + SignerStorage::Temporary => "Temporary".to_string(), + } +} - env.conclude(signers); +/// Map a stored `exp` and the current ledger timestamp to (expiration, expired). +/// Semantics: valid while `now <= exp` (inclusive); expired once `now > exp`. +fn expiry(exp: i64, now: u64) -> (Option, bool) { + if exp == EXP_NEVER { + (None, false) + } else { + (Some(exp as u64), (now as i64) > exp) + } } diff --git a/zephyr/src/types.rs b/zephyr/src/types.rs index 38483f6..141cfb7 100644 --- a/zephyr/src/types.rs +++ b/zephyr/src/types.rs @@ -1,4 +1,14 @@ -use serde::Serialize; +//! Database row structs for the two indexer tables (`signers`, `wallets`). +//! +//! `DatabaseDerive` maps each field to a column serialization by its Rust type: +//! `ScVal` -> XDR bytes (BYTEA); integers (`i64`) -> ZephyrVal (BIGINT); every +//! other type (here `bool`) -> bincode (BYTEA). Partial structs select a subset +//! of columns by name for reads/updates. +//! +//! NOTE: the `DatabaseDerive` macro panics on ANY struct-level attribute other +//! than `#[with_name]` (a `///` doc comment becomes a `#[doc]` attribute and +//! trips it), so these structs are documented with plain `//` comments. + use zephyr_sdk::{ bincode, prelude::{Limits, ReadXdr, WriteXdr}, @@ -6,46 +16,105 @@ use zephyr_sdk::{ Condition, DatabaseDerive, DatabaseInteract, EnvClient, ZephyrVal, }; -#[derive(DatabaseDerive, Serialize, Clone)] +/// Sentinel `exp` value meaning "never expires" (`SignerExpiration(None)`). +/// Chosen as `i64::MAX` so the inclusive `now <= exp` check is always true and +/// it fits the BIGINT column. +pub const EXP_NEVER: i64 = i64::MAX; + +// --- signers table ---------------------------------------------------------- + +// One row per (wallet address, signer key). `active` is a soft-delete flag: +// there is no row-delete host op, so `remove_signer` flips `active` to false. +#[derive(DatabaseDerive, Clone)] #[with_name("signers")] -pub struct Signers { +pub struct Signer { + /// Wallet contract address, as an `ScVal::Address`. pub address: ScVal, + /// `SignerKey` ScVal (as emitted in event topic 1). pub key: ScVal, + /// secp256r1 public key as `ScVal::Bytes(65)`, or `ScVal::Void` for + /// Ed25519/Policy signers (which have no separate public key). pub val: ScVal, + /// `SignerLimits` ScVal. pub limits: ScVal, - pub exp: u32, + /// Expiration UNIX timestamp (seconds), or `EXP_NEVER`. + pub exp: i64, + /// `SignerStorage` ScVal (Persistent | Temporary). pub storage: ScVal, - pub active: ScVal, + /// Live (true) or removed (false). + pub active: bool, } -#[derive(DatabaseDerive, Serialize, Clone)] +// Columns written when reactivating/updating an existing signer row (the +// (address, key) pair is supplied as the update filter, not rewritten). +#[derive(DatabaseDerive, Clone)] #[with_name("signers")] -pub struct SignersKeyValLimitsExpStorage { - pub key: ScVal, +pub struct SignerMutation { pub val: ScVal, pub limits: ScVal, - pub exp: u32, + pub exp: i64, pub storage: ScVal, + pub active: bool, +} + +// Existence probe for a (address, key) pair. +#[derive(DatabaseDerive, Clone)] +#[with_name("signers")] +pub struct SignerKeyOnly { + pub key: ScVal, +} + +// Column written to soft-delete a signer on `remove_signer` (keeps every other +// column intact; the (address, key) pair is the update filter). +#[derive(DatabaseDerive, Clone)] +#[with_name("signers")] +pub struct SignerActiveMutation { + pub active: bool, } -#[derive(DatabaseDerive, Serialize, Clone)] +// Projection for `get_signers_by_address`. +#[derive(DatabaseDerive, Clone)] #[with_name("signers")] -pub struct SignersValLimitsExpStorageActive { +pub struct SignerReadRow { + pub key: ScVal, pub val: ScVal, pub limits: ScVal, - pub exp: u32, + pub exp: i64, pub storage: ScVal, - pub active: ScVal, } -#[derive(DatabaseDerive, Serialize, Clone)] +// Projection for `get_addresses_by_signer` (reverse lookup). +#[derive(DatabaseDerive, Clone)] #[with_name("signers")] -pub struct SignersAddress { +pub struct WalletMatchRow { pub address: ScVal, + pub exp: i64, + pub storage: ScVal, } -#[derive(DatabaseDerive, Serialize, Clone, Debug)] -#[with_name("signers")] -pub struct SignersActive { - pub active: ScVal, +// --- wallets table ---------------------------------------------------------- + +// One row per trusted wallet: an allowlisted smart-wallet instance observed in +// the ledger. Membership is the ingestion trust gate (audit F3) and records +// the latest known WASM hash (also updated from `upgraded` events). +#[derive(DatabaseDerive, Clone)] +#[with_name("wallets")] +pub struct Wallet { + pub address: ScVal, + /// Latest known executable WASM hash, as `ScVal::Bytes(32)`. + pub wasm_hash: ScVal, +} + +// Existence probe / trust check for a wallet address. +#[derive(DatabaseDerive, Clone)] +#[with_name("wallets")] +pub struct WalletAddrOnly { + pub address: ScVal, +} + +// Column written when refreshing a known wallet's WASM hash. +#[derive(DatabaseDerive, Clone)] +#[with_name("wallets")] +pub struct WalletHashMutation { + pub wasm_hash: ScVal, } diff --git a/zephyr/src/wallet.rs b/zephyr/src/wallet.rs new file mode 100644 index 0000000..f9d854c --- /dev/null +++ b/zephyr/src/wallet.rs @@ -0,0 +1,130 @@ +//! Local, version-decoupled mirror of the smart wallet's on-chain types plus +//! the v1 `#[contractevent]` payload shapes the indexer decodes. +//! +//! These `#[contracttype]` definitions are byte-for-byte ScVal-compatible with +//! the contract's `smart-wallet-interface` types, but are built on THIS crate's +//! single soroban-sdk (22.x, brought in by zephyr-sdk) rather than the +//! contract's soroban-sdk 27. Depending on `smart-wallet-interface` directly +//! would pull a SECOND soroban-sdk into the tree and fail the build — that dual +//! soroban-sdk is exactly what audit F1 flagged. Mirroring the few types we +//! decode keeps the indexer decoupled from the contract crate's toolchain while +//! staying wire-compatible (the ScVal enum/struct encoding is protocol-stable +//! across soroban-sdk versions). + +use soroban_sdk::{contracttype, Address, Bytes, BytesN, Map, Vec}; + +// --- Signer model (mirror of smart-wallet-interface::types) ----------------- + +/// UNIX timestamp (seconds) expiration, inclusive; `None` never expires. v1 +/// changed this from a ledger sequence number (audit F12). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerExpiration(pub Option); + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerLimits(pub Option>>>); + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SignerStorage { + Persistent, + Temporary, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SignerKey { + Policy(Address), + Ed25519(BytesN<32>), + Secp256r1(Bytes), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SignerVal { + Policy(SignerExpiration, SignerLimits), + Ed25519(SignerExpiration, SignerLimits), + Secp256r1(BytesN<65>, SignerExpiration, SignerLimits), +} + +impl SignerVal { + /// Split a stored signer value into its indexable parts: the secp256r1 + /// public key (only present for passkey signers), expiration, and limits. + pub fn into_parts(self) -> (Option>, SignerExpiration, SignerLimits) { + match self { + SignerVal::Policy(exp, limits) => (None, exp, limits), + SignerVal::Ed25519(exp, limits) => (None, exp, limits), + SignerVal::Secp256r1(public_key, exp, limits) => (Some(public_key), exp, limits), + } + } +} + +// --- Event data payloads ---------------------------------------------------- +// +// The v1 events are `#[contractevent]` structs whose default `data_format` is +// `map`: the non-`#[topic]` fields are emitted as an ScVal::Map keyed by field +// name. The `#[topic]` `key` field is NOT part of the data (it's topic index +// 1). Struct decode is by field name, so the declaration order below is +// irrelevant. + +/// Data of `signer_added` — topics: `[sym("signer_added"), ]`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerAddedData { + pub storage: SignerStorage, + pub val: SignerVal, +} + +/// Data of `signer_updated` — topics: `[sym("signer_updated"), ]`. +/// `old_storage` is the durability the entry lived in before the update (the +/// contract tombstones the old durability if the update flips it; the indexer +/// keeps a single row per (wallet, key) and simply rewrites `storage`). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerUpdatedData { + pub old_storage: SignerStorage, + pub storage: SignerStorage, + pub val: SignerVal, +} + +/// Data of `signer_removed` — topics: `[sym("signer_removed"), ]`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerRemovedData { + pub storage: SignerStorage, +} + +/// Data of `upgraded` — topics: `[sym("upgraded")]`. `old_hash` is `None` on a +/// wallet's first-ever upgrade. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpgradedData { + pub new_hash: BytesN<32>, + pub old_hash: Option>, +} + +// --- WASM-hash allowlist (audit F3) ----------------------------------------- + +/// Known passkey-kit smart-wallet WASM hashes (hex), sourced from +/// `docs/deployments-testnet-2026-07-11.md` — the canonical hash manifest. +/// NEVER rebuild these locally; consume the manifest. +/// +/// Only events emitted by a contract whose on-chain instance executable is one +/// of these are ingested. Without this gate any contract emitting well-formed +/// `signer_added`/… events could inject arbitrary signer rows and poison the +/// reverse (keyId -> wallet) lookup (audit F3). +pub const ALLOWLISTED_WASM_HASHES: [&str; 2] = [ + // v1 smart wallet — soroban-sdk 27, #[contractevent] schema. + "9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe", + // Legacy pre-1.0 wallet — sw_v1 tuple events (ignored by this indexer), but + // a known smart wallet: a legacy wallet can upgrade in place to a v1 hash + // and keep its address, at which point it starts emitting v1 events. + "e45c42b944a767bd5f37f8c4a469b48917d28e23481dbfd550419c84cdacde92", +]; + +/// Whether a 32-byte WASM hash is a known passkey-kit smart wallet. +pub fn is_allowlisted(wasm_hash: &[u8; 32]) -> bool { + let observed = hex::encode(wasm_hash); + ALLOWLISTED_WASM_HASHES.iter().any(|h| *h == observed) +} diff --git a/zephyr/zephyr.toml b/zephyr/zephyr.toml index 5406a6f..b05ffe5 100644 --- a/zephyr/zephyr.toml +++ b/zephyr/zephyr.toml @@ -1,5 +1,8 @@ -name = "smart-wallets-next-dima" +name = "passkey-kit-indexer" +# Signer index: one row per (wallet address, signer key). `active` is a +# soft-delete flag (no row-delete host op exists). `exp` is a UNIX timestamp +# (seconds) or i64::MAX for "never". [[tables]] name = "signers" force = false @@ -32,4 +35,20 @@ col_type = "BYTEA" [[tables.columns]] name = "active" -col_type = "BYTEA" \ No newline at end of file +col_type = "BYTEA" + +# Trusted wallets: allowlisted smart-wallet instances observed on-chain. Table +# membership is the ingestion trust gate (audit F3); `wasm_hash` records the +# latest known executable (refreshed from `upgraded` events). +[[tables]] +name = "wallets" +force = false + +[[tables.columns]] +name = "address" +col_type = "BYTEA" +index = true + +[[tables.columns]] +name = "wasm_hash" +col_type = "BYTEA" From 73807666f073f08117b0f403f5ded8b846309f9c Mon Sep 17 00:00:00 2001 From: kalepail Date: Sat, 11 Jul 2026 22:32:59 -0400 Subject: [PATCH 16/30] D2 e2e: agent-browser WebAuthn harness + testnet smoke/audit scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the smart-account-kit 4-layer e2e harness to passkey-kit, under scripts/e2e/, driving the real Svelte demo with data-testid selectors (no React native-setter shims). Wired as pnpm scripts in demo/package.json (root package.json is off-limits). Layers: - agent-browser-webauthn-helper.mjs: attaches a CDP virtual authenticator (ctap2 / internal / residentKey / userVerified / automaticPresenceSimulation) to a live agent-browser session and tears it down in a finally. Generic port. - webauthn-browser-probe.sh: isolated create -> get -> crypto.subtle.verify P-256 sanity, asserts verified:true. Generic port. - testnet-passkey-smoke.sh: orchestration. Idempotent Vite start (lsof guard), EXIT trap that kills only the server it started and closes only its own agent-browser session (no orphaned Chrome-for-Testing). MODE=probe|audit. - browser-full-e2e-audit.sh: the passkey-kit flow — create -> reconnect (discoverable passkey, ownership verified) -> fund -> transfer -> add secp256r1 / Ed25519 / policy signers -> update -> per-signer transfer -> remove -> discovery via BOTH indexer backends (lookupWithRetry poll) -> reverse lookup. Asserts REAL outputs (C... contract id, 64-hex tx hashes), an explicit failure pattern on every wait, and a DISTINCT exit code per layer (10/20/21/30/31/ 40..45/50/51/52). Auto-gates: skips on-chain layers when the demo header shows "no relayer proxy" and discovery when "no indexer proxy" (explicit log, never a silent pass) so it is meaningful before C2/B5 land; full live run is F2. Demo selector hooks (D2 scope: "demo selectors as needed"): scoped log-box testid + full data-hash on tx links; signer-update / signer-update-save / signer-set-active / use-fund-addr testids. Static-validated: node --check (mjs) + bash -n (all sh) clean; pnpm build:demo green (svelte-check 0/0 + vite build). Live browser execution is F2 (orchestrator- gated) per the master plan; the harness is isolation-safe (unique session, lsof guard, session-scoped close) for that run. Refs #600 s6/s14, TOPROMPT verify rules. Co-Authored-By: Claude Opus 4.8 --- demo/package.json | 6 +- demo/src/lib/components/ActivityLog.svelte | 12 +- demo/src/lib/components/SignerRow.svelte | 6 +- demo/src/lib/components/TransferPanel.svelte | 2 +- scripts/e2e/agent-browser-webauthn-helper.mjs | 325 ++++++++++++++++++ scripts/e2e/browser-full-e2e-audit.sh | 294 ++++++++++++++++ scripts/e2e/testnet-passkey-smoke.sh | 95 +++++ scripts/e2e/webauthn-browser-probe.sh | 132 +++++++ 8 files changed, 863 insertions(+), 9 deletions(-) create mode 100755 scripts/e2e/agent-browser-webauthn-helper.mjs create mode 100755 scripts/e2e/browser-full-e2e-audit.sh create mode 100755 scripts/e2e/testnet-passkey-smoke.sh create mode 100755 scripts/e2e/webauthn-browser-probe.sh diff --git a/demo/package.json b/demo/package.json index 8aabc4e..7f677c0 100644 --- a/demo/package.json +++ b/demo/package.json @@ -7,7 +7,11 @@ "start": "vite", "build": "svelte-check --tsconfig ./tsconfig.json && vite build", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json" + "check": "svelte-check --tsconfig ./tsconfig.json", + "e2e:webauthn": "node ../scripts/e2e/agent-browser-webauthn-helper.mjs", + "e2e:probe": "MODE=probe bash ../scripts/e2e/testnet-passkey-smoke.sh", + "e2e:smoke": "MODE=audit bash ../scripts/e2e/testnet-passkey-smoke.sh", + "e2e:audit": "MODE=audit bash ../scripts/e2e/testnet-passkey-smoke.sh" }, "devDependencies": { "@stellar/stellar-sdk": "16.0.1", diff --git a/demo/src/lib/components/ActivityLog.svelte b/demo/src/lib/components/ActivityLog.svelte index 4076e42..c3ffe98 100644 --- a/demo/src/lib/components/ActivityLog.svelte +++ b/demo/src/lib/components/ActivityLog.svelte @@ -14,7 +14,7 @@ -
    +
    {#each app.log as entry (entry.id)}
    @@ -25,9 +25,13 @@ {#if entry.hash} {/if}
    diff --git a/demo/src/lib/components/SignerRow.svelte b/demo/src/lib/components/SignerRow.svelte index 7bb9d5b..40f40a0 100644 --- a/demo/src/lib/components/SignerRow.svelte +++ b/demo/src/lib/components/SignerRow.svelte @@ -66,9 +66,9 @@ Transfer 1 {#if signer.kind === "Secp256r1" && isAdmin(signer) && !active} - + {/if} - + @@ -92,7 +92,7 @@
    - +
    diff --git a/demo/src/lib/components/TransferPanel.svelte b/demo/src/lib/components/TransferPanel.svelte index ced2b6d..a4f429d 100644 --- a/demo/src/lib/components/TransferPanel.svelte +++ b/demo/src/lib/components/TransferPanel.svelte @@ -76,7 +76,7 @@
    - +
    diff --git a/scripts/e2e/agent-browser-webauthn-helper.mjs b/scripts/e2e/agent-browser-webauthn-helper.mjs new file mode 100755 index 0000000..00a926c --- /dev/null +++ b/scripts/e2e/agent-browser-webauthn-helper.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node + +/** + * Attach a virtual WebAuthn authenticator (CDP `WebAuthn.addVirtualAuthenticator`) + * to a live agent-browser session, keep the CDP attachment open while a child + * command runs, and tear the authenticator down in a `finally` — so passkey + * ceremonies resolve headlessly and nothing leaks between runs. + * + * Ported from smart-account-kit (project 34); generic — no passkey-kit specifics. + * + * Usage: + * node scripts/e2e/agent-browser-webauthn-helper.mjs run --session -- [args...] + */ + +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_AUTHENTICATOR_OPTIONS = { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, +}; + +function printUsage() { + console.error(` +Usage: + node scripts/e2e/agent-browser-webauthn-helper.mjs run [options] -- [args...] + +Description: + Attaches a virtual WebAuthn authenticator to a live agent-browser session, keeps + the CDP attachment open while your command runs, and cleans it up afterward. + +Options: + --session Existing agent-browser session name + --cdp-url Explicit CDP WebSocket URL (overrides --session lookup) + --url Page URL to bind within the browser session + --transport internal | usb | nfc | ble | cable + --protocol ctap2 | u2f + --resident-key true | false + --user-verification true | false + --verified true | false + --presence true | false + +Example: + agent-browser --session demo open http://127.0.0.1:5173 + node scripts/e2e/agent-browser-webauthn-helper.mjs run --session demo -- \\ + bash scripts/e2e/browser-full-e2e-audit.sh +`); +} + +function parseArgs(argv) { + const normalized = argv[0] === "--" ? argv.slice(1) : argv; + const command = normalized[0]; + if (!command) { + printUsage(); + process.exit(1); + } + + const separator = normalized.indexOf("--"); + const optionTokens = + separator === -1 ? normalized.slice(1) : normalized.slice(1, separator); + const childCommand = separator === -1 ? [] : normalized.slice(separator + 1); + + const args = { _: [] }; + for (let i = 0; i < optionTokens.length; i += 1) { + const token = optionTokens[i]; + if (!token.startsWith("--")) { + args._.push(token); + continue; + } + + const key = token.slice(2); + const next = optionTokens[i + 1]; + if (!next || next.startsWith("--")) { + args[key] = true; + continue; + } + + args[key] = next; + i += 1; + } + + return { command, args, childCommand }; +} + +function parseBoolean(value, fallback) { + if (value === undefined) return fallback; + if (typeof value === "boolean") return value; + + const normalized = String(value).toLowerCase(); + if (["true", "1", "yes", "on"].includes(normalized)) return true; + if (["false", "0", "no", "off"].includes(normalized)) return false; + throw new Error(`Expected a boolean value, received "${value}"`); +} + +async function runAgentBrowser(args) { + const { stdout } = await execFileAsync("agent-browser", args, { + encoding: "utf8", + }); + return stdout.trim(); +} + +function getLastNonEmptyLine(output) { + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); +} + +async function resolveCDPUrl(args) { + if (args["cdp-url"] && args["cdp-url"] !== true) return args["cdp-url"]; + + const session = args.session; + if (!session || session === true) { + throw new Error("Provide --session or --cdp-url"); + } + + const output = await runAgentBrowser(["--session", session, "get", "cdp-url"]); + const cdpUrl = getLastNonEmptyLine(output); + if (!cdpUrl) { + throw new Error(`Unable to resolve CDP URL for session "${session}"`); + } + return cdpUrl; +} + +async function resolveCurrentUrl(args) { + if (args.url && args.url !== true) return args.url; + + const session = args.session; + if (!session || session === true) return null; + + const output = await runAgentBrowser(["--session", session, "get", "url"]); + return getLastNonEmptyLine(output) ?? null; +} + +function createCDPConnection(browserWsUrl) { + const socket = new WebSocket(browserWsUrl); + let nextId = 0; + const pending = new Map(); + + socket.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.id && pending.has(message.id)) { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) reject(new Error(JSON.stringify(message.error))); + else resolve(message.result); + } + }; + + const ready = new Promise((resolve, reject) => { + socket.onopen = () => resolve(); + socket.onerror = (event) => + reject(new Error(`WebSocket error: ${event.type}`)); + }); + + const send = (method, params = {}, sessionId) => + new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + const payload = { id, method, params }; + if (sessionId) payload.sessionId = sessionId; + socket.send(JSON.stringify(payload)); + }); + + const close = () => { + if ( + socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING + ) { + socket.close(); + } + }; + + return { ready, send, close }; +} + +async function attachToPage(send, preferredUrl) { + const { targetInfos } = await send("Target.getTargets"); + const pages = targetInfos.filter((target) => target.type === "page"); + + const pageTarget = + (preferredUrl + ? pages.find((target) => target.url === preferredUrl) + : null) ?? + pages.find((target) => target.url && target.url !== "about:blank") ?? + pages.at(-1); + + if (!pageTarget) { + throw new Error("Unable to find a page target in the remote browser"); + } + + const { sessionId } = await send("Target.attachToTarget", { + targetId: pageTarget.targetId, + flatten: true, + }); + + return { sessionId, pageUrl: pageTarget.url, targetId: pageTarget.targetId }; +} + +async function runChildCommand(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "inherit", env: process.env }); + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + reject(new Error(`Child command exited via signal ${signal}`)); + return; + } + resolve(code ?? 0); + }); + }); +} + +async function commandRun(args, childCommand) { + if (childCommand.length === 0) { + throw new Error("run requires a child command after --"); + } + + const cdpUrl = await resolveCDPUrl(args); + const preferredUrl = await resolveCurrentUrl(args); + const connection = createCDPConnection(cdpUrl); + await connection.ready; + + const { sessionId, pageUrl } = await attachToPage( + connection.send, + preferredUrl, + ); + const authenticatorOptions = { + protocol: args.protocol || DEFAULT_AUTHENTICATOR_OPTIONS.protocol, + transport: args.transport || DEFAULT_AUTHENTICATOR_OPTIONS.transport, + hasResidentKey: parseBoolean( + args["resident-key"], + DEFAULT_AUTHENTICATOR_OPTIONS.hasResidentKey, + ), + hasUserVerification: parseBoolean( + args["user-verification"], + DEFAULT_AUTHENTICATOR_OPTIONS.hasUserVerification, + ), + isUserVerified: parseBoolean( + args.verified, + DEFAULT_AUTHENTICATOR_OPTIONS.isUserVerified, + ), + automaticPresenceSimulation: parseBoolean( + args.presence, + DEFAULT_AUTHENTICATOR_OPTIONS.automaticPresenceSimulation, + ), + }; + + await connection.send("WebAuthn.enable", {}, sessionId); + const { authenticatorId } = await connection.send( + "WebAuthn.addVirtualAuthenticator", + { options: authenticatorOptions }, + sessionId, + ); + + console.error( + JSON.stringify( + { + session: typeof args.session === "string" ? args.session : null, + cdpUrl, + pageUrl, + authenticatorId, + options: authenticatorOptions, + }, + null, + 2, + ), + ); + + let exitCode = 0; + try { + exitCode = await runChildCommand(childCommand[0], childCommand.slice(1)); + } finally { + try { + await connection.send( + "WebAuthn.removeVirtualAuthenticator", + { authenticatorId }, + sessionId, + ); + } catch (error) { + console.error( + `Cleanup warning: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + await connection.send("WebAuthn.disable", {}, sessionId); + } catch (error) { + console.error( + `Cleanup warning: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + connection.close(); + } + + process.exitCode = exitCode; +} + +const { command, args, childCommand } = parseArgs(process.argv.slice(2)); + +try { + switch (command) { + case "run": + await commandRun(args, childCommand); + break; + case "help": + case "--help": + case "-h": + printUsage(); + break; + default: + throw new Error(`Unknown command "${command}"`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/e2e/browser-full-e2e-audit.sh b/scripts/e2e/browser-full-e2e-audit.sh new file mode 100755 index 0000000..c8e773b --- /dev/null +++ b/scripts/e2e/browser-full-e2e-audit.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash + +# Full browser e2e audit for the passkey-kit demo. +# +# Drives the real Svelte demo through the passkey-kit flow and asserts REAL +# outputs (a C… contract id, 64-hex tx hashes, explicit failure patterns on every +# wait). Uses Svelte `data-testid` selectors — no React native-setter shims. +# +# Runs inside testnet-passkey-smoke.sh (a live agent-browser session with a +# virtual WebAuthn authenticator). Layers gate automatically: if the demo header +# shows "no relayer proxy" the on-chain layers are skipped (explicitly, never a +# silent pass); discovery is skipped when "no indexer proxy" is shown. +# +# Distinct exit code per layer: +# 10 setup 20 create 21 reconnect 30 fund 31 transfer +# 40 add-secp 41 add-ed25519 42 add-policy 43 update 44 per-signer-transfer 45 remove +# 50 discover-mercury 51 discover-stellar-indexer 52 reverse-lookup + +set -euo pipefail + +SESSION_NAME="${SESSION_NAME:?SESSION_NAME is required}" +DEMO_URL="${DEMO_URL:-http://127.0.0.1:5173}" +SKIP_ONCHAIN="${SKIP_ONCHAIN:-auto}" +# Destination for the primary transfer; falls back to the demo's own funding +# address (always exists) via the "use fund addr" control when unset. +RECIPIENT_ADDRESS="${RECIPIENT_ADDRESS:-}" + +ab() { agent-browser --session "$SESSION_NAME" "$@"; } + +CURRENT_LAYER="setup" +CURRENT_CODE=10 +layer() { + CURRENT_LAYER="$1" + CURRENT_CODE="$2" + echo "── [$CURRENT_CODE] $CURRENT_LAYER ────────────────────────────────" +} +fail() { + echo "FAIL [$CURRENT_CODE] $CURRENT_LAYER: $1" >&2 + echo "---- log-box ----" >&2 + log_box >&2 || true + exit "$CURRENT_CODE" +} + +log_box() { ab get text '[data-testid="log-box"]' 2>/dev/null || true; } +header_text() { ab get text '[data-testid="header"]' 2>/dev/null || true; } +body_text() { ab get text body 2>/dev/null || true; } +contract_id() { ab get text '[data-testid="wallet-contract-id"]' 2>/dev/null | tr -d '[:space:]'; } +# querySelector returns the FIRST (newest, list is prepended) tx-hash → deterministic. +latest_hash() { + ab eval 'document.querySelector("[data-testid=\"tx-hash\"]")?.dataset.hash ?? ""' 2>/dev/null | tr -d '[:space:]"' +} +log_line_count() { printf '%s\n' "$(log_box)" | wc -l | tr -d ' '; } + +# Wait for a NEW log entry (newest-first) matching success/failure since a prior +# line count. Returns 0 success, 2 failure-pattern, 1 timeout. +wait_new_log() { + local previous_count="$1" success="$2" failure="$3" attempts="${4:-60}" + for _ in $(seq 1 "$attempts"); do + local all total new_count slice + all="$(log_box)" + total="$(printf '%s\n' "$all" | wc -l | tr -d ' ')" + new_count=$(( total - previous_count )) + if (( new_count > 0 )); then + slice="$(printf '%s\n' "$all" | head -n "$new_count")" + else + slice="" + fi + if [[ -n "$failure" ]] && printf '%s\n' "$slice" | grep -Eq "$failure"; then + return 2 + fi + if printf '%s\n' "$slice" | grep -Eq "$success"; then + return 0 + fi + sleep 2 + done + return 1 +} + +# Click a testid; fail the layer if the element is missing. +click_tid() { + ab click "[data-testid=\"$1\"]" >/dev/null 2>&1 || fail "missing/again unclickable: $1" +} +fill_tid() { + ab fill "[data-testid=\"$1\"]" "$2" >/dev/null 2>&1 || fail "missing input: $1" +} +has_tid() { [[ "$(ab get count "[data-testid=\"$1\"]" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; } + +# Run one write layer: capture the log count, run the click closure, wait. +expect_after() { + local success="$1" failure="$2" attempts="${3:-90}"; shift 3 + local before; before="$(log_line_count)" + "$@" + case "$(wait_new_log "$before" "$success" "$failure" "$attempts"; echo $?)" in + 0) echo " ✓ $success" ;; + 2) fail "hit failure pattern ($failure)" ;; + *) fail "timed out waiting for: $success" ;; + esac +} + +######################################################################## +layer "setup" 10 +echo "Opening $DEMO_URL" +ab open "$DEMO_URL" >/dev/null || fail "could not open demo" +sleep 2 +# The demo must have mounted (header rendered). A blank body means the module +# crashed — usually a missing demo/.env.local (copy demo/.env.example). +if ! printf '%s\n' "$(body_text)" | grep -q "Passkey Kit"; then + fail "demo did not render — configure demo/.env.local (copy demo/.env.example)" +fi +HEADER="$(header_text)" +echo " header: $HEADER" + +ONCHAIN=1 +case "$SKIP_ONCHAIN" in + 1|true|yes) ONCHAIN=0 ;; + 0|false|no) ONCHAIN=1 ;; + *) printf '%s\n' "$HEADER" | grep -q "no relayer proxy" && ONCHAIN=0 ;; +esac +INDEXER=1 +printf '%s\n' "$HEADER" | grep -q "no indexer proxy" && INDEXER=0 + +if (( ONCHAIN == 0 )); then + echo + echo "on-chain layers SKIPPED: relayer proxy not configured." + echo "Set VITE_relayerProxyUrl in demo/.env.local (and start the relayer-proxy" + echo "worker) to run create → transfer → signer → discovery live (F2)." + # Still assert the disconnected UI is wired. + has_tid "register" || fail "register control missing on disconnected UI" + echo "Disconnected UI wired (register control present). Static UI audit passed." + exit 0 +fi + +######################################################################## +layer "create" 20 +USER_NAME="audit$(date +%s)" +echo "Creating wallet for $USER_NAME" +fill_tid "register-name" "$USER_NAME" +expect_after "Wallet created" "Create wallet failed|Deploy wallet failed" 150 click_tid "register" +CONTRACT_ID="$(contract_id)" +printf '%s\n' "$CONTRACT_ID" | grep -Eq '^C[A-Z2-7]{55}$' || fail "no valid contract id (got: '$CONTRACT_ID')" +echo " contract: $CONTRACT_ID" +CREATE_HASH="$(latest_hash)" +printf '%s\n' "$CREATE_HASH" | grep -Eq '^[0-9a-f]{64}$' || fail "no valid deploy tx hash (got: '$CREATE_HASH')" +echo " deploy tx: $CREATE_HASH" + +######################################################################## +layer "reconnect" 21 +echo "Disconnect + reconnect via discoverable passkey" +click_tid "disconnect" +sleep 1 +has_tid "signin" || fail "signin control missing after disconnect" +expect_after "Connected — ownership verified" "Connect wallet failed" 120 click_tid "signin" +RECONNECT_ID="$(contract_id)" +[[ "$RECONNECT_ID" == "$CONTRACT_ID" ]] || fail "reconnected to a different wallet ($RECONNECT_ID != $CONTRACT_ID)" +echo " reconnected to $RECONNECT_ID (ownership verified)" + +######################################################################## +layer "fund" 30 +echo "Funding wallet" +expect_after "Fund wallet ✓" "Fund wallet failed|Create wallet failed" 150 click_tid "fund-wallet" + +######################################################################## +layer "transfer" 31 +echo "Primary transfer (signed by passkey)" +click_tid "use-fund-addr" +sleep 1 +[[ -n "$RECIPIENT_ADDRESS" ]] && fill_tid "transfer-to" "$RECIPIENT_ADDRESS" +fill_tid "transfer-amount" "1" +expect_after "Transfer \(passkey\) ✓" "Transfer \(passkey\) failed" 150 click_tid "transfer-send" +TRANSFER_HASH="$(latest_hash)" +printf '%s\n' "$TRANSFER_HASH" | grep -Eq '^[0-9a-f]{64}$' || fail "no valid transfer tx hash" +echo " transfer tx: $TRANSFER_HASH" + +######################################################################## +layer "add-secp" 40 +echo "Add secp256r1 passkey signer" +click_tid "add-kind-Secp256r1" +fill_tid "add-signer-name" "session-$(date +%s)" +expect_after "Add passkey signer ✓" "Add passkey signer failed" 150 click_tid "add-signer-submit" + +######################################################################## +layer "add-ed25519" 41 +echo "Add Ed25519 signer" +click_tid "add-kind-Ed25519" +expect_after "Add Ed25519 signer ✓" "Add Ed25519 signer failed" 150 click_tid "add-signer-submit" + +######################################################################## +layer "add-policy" 42 +POLICY_ENABLED="$(ab eval 'document.querySelector("[data-testid=\"add-kind-Policy\"]")?.disabled === false' 2>/dev/null | tr -d '[:space:]')" +if [[ "$POLICY_ENABLED" == "true" ]]; then + echo "Add policy signer" + click_tid "add-kind-Policy" + expect_after "Add policy signer ✓" "Add policy signer failed" 150 click_tid "add-signer-submit" +else + echo " SKIP: no VITE_samplePolicyId configured (policy-signer controls disabled)" +fi + +######################################################################## +layer "update" 43 +echo "Update the Ed25519 signer (limits/store)" +ED_ROW='[data-testid="signer-row"][data-kind="Ed25519"]' +if [[ "$(ab get count "$ED_ROW" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; then + ab click "$ED_ROW [data-testid=\"signer-update\"]" >/dev/null 2>&1 || fail "update control missing" + sleep 1 + before="$(log_line_count)" + ab click "$ED_ROW [data-testid=\"signer-update-save\"]" >/dev/null 2>&1 || fail "update-save missing" + case "$(wait_new_log "$before" "Update Ed25519 signer ✓" "Update Ed25519 signer failed" 150; echo $?)" in + 0) echo " ✓ Update Ed25519 signer ✓" ;; + 2) fail "update hit failure pattern" ;; + *) fail "update timed out" ;; + esac +else + echo " SKIP: no Ed25519 row present" +fi + +######################################################################## +layer "per-signer-transfer" 44 +echo "Per-signer transfer via the Ed25519 signer row" +if [[ "$(ab get count "$ED_ROW" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; then + before="$(log_line_count)" + ab click "$ED_ROW [data-testid=\"signer-transfer\"]" >/dev/null 2>&1 || fail "per-signer transfer control missing" + case "$(wait_new_log "$before" "Transfer via Ed25519 ✓" "Transfer via Ed25519 failed" 150; echo $?)" in + 0) echo " ✓ Transfer via Ed25519 ✓" ;; + 2) fail "per-signer transfer hit failure pattern" ;; + *) fail "per-signer transfer timed out" ;; + esac +else + echo " SKIP: no Ed25519 row present" +fi + +######################################################################## +layer "remove" 45 +echo "Remove the Ed25519 signer" +if [[ "$(ab get count "$ED_ROW" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; then + before="$(log_line_count)" + ab click "$ED_ROW [data-testid=\"signer-remove\"]" >/dev/null 2>&1 || fail "remove control missing" + case "$(wait_new_log "$before" "Remove Ed25519 signer ✓" "Remove Ed25519 signer failed" 150; echo $?)" in + 0) echo " ✓ Remove Ed25519 signer ✓" ;; + 2) fail "remove hit failure pattern" ;; + *) fail "remove timed out" ;; + esac +else + echo " SKIP: no Ed25519 row present" +fi + +######################################################################## +if (( INDEXER == 0 )); then + echo + echo "discovery layers SKIPPED: indexer proxy not configured." + echo "Set VITE_indexerProxyUrl in demo/.env.local to run Mercury + Stellar" + echo "Indexer discovery live (F2)." + echo + echo "On-chain audit passed for contract $CONTRACT_ID" + exit 0 +fi + +# lookupWithRetry: indexers lag the ledger, so poll until a signer is discovered. +discover_backend() { + local backend="$1" code="$2" + layer "discover-$backend" "$code" + click_tid "backend-$backend" + local ok="" + for _ in $(seq 1 20); do + before="$(log_line_count)" + ab click '[data-testid="discover"]' >/dev/null 2>&1 || fail "discover control missing" + if wait_new_log "$before" "Discovered [1-9][0-9]* signer\(s\) via $backend" "Discover via $backend failed" 6; then + ok=1 + break + fi + sleep 3 + done + [[ -n "$ok" ]] || fail "no signers discovered via $backend after retries" + [[ "$(ab get count '[data-testid="discovered-list"] .signer' 2>/dev/null | tr -d '[:space:]')" != "0" ]] \ + || fail "discovered list empty for $backend" + echo " ✓ discovery via $backend returned signers" +} + +discover_backend "mercury" 50 +discover_backend "stellar-indexer" 51 + +layer "reverse-lookup" 52 +echo "Reverse lookup keyId → wallet(s)" +before="$(log_line_count)" +click_tid "reverse-lookup" +case "$(wait_new_log "$before" "matches connected ✓|keyId → [1-9][0-9]* wallet" "Reverse lookup .* failed" 60; echo $?)" in + 0) echo " ✓ reverse lookup resolved the wallet" ;; + 2) fail "reverse lookup hit failure pattern" ;; + *) fail "reverse lookup timed out" ;; +esac + +echo +echo "FULL e2e audit passed for contract $CONTRACT_ID" +echo " deploy tx: $CREATE_HASH" +echo " transfer tx: $TRANSFER_HASH" diff --git a/scripts/e2e/testnet-passkey-smoke.sh b/scripts/e2e/testnet-passkey-smoke.sh new file mode 100755 index 0000000..176b12b --- /dev/null +++ b/scripts/e2e/testnet-passkey-smoke.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +# Testnet smoke orchestration for the passkey-kit demo. +# +# - Starts the demo's Vite server (idempotently — an lsof guard skips the start +# and the kill if something is already listening on the port). +# - Opens a fresh agent-browser session and attaches a virtual WebAuthn +# authenticator (via agent-browser-webauthn-helper.mjs) for the child run. +# - EXIT trap kills ONLY the server this script started and closes ONLY this +# script's agent-browser session — no orphaned Chrome-for-Testing. +# +# MODE selects the child: `audit` (default, full e2e) or `probe` (backend-free +# WebAuthn sanity). Demo config comes from demo/.env.local (copy .env.example). +# Ported from smart-account-kit (project 34); adapted to the single passkey-kit demo. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DEMO_DIR="$ROOT_DIR/demo" + +DEV_HOST="${DEV_HOST:-127.0.0.1}" +DEMO_PORT="${DEMO_PORT:-5173}" +DEMO_URL="${DEMO_URL:-http://${DEV_HOST}:${DEMO_PORT}}" +SESSION_NAME="${SESSION_NAME:-passkey-kit-smoke-$(date +%s)}" +MODE="${MODE:-audit}" +case "$MODE" in + probe) E2E_CHILD="${E2E_CHILD:-$ROOT_DIR/scripts/e2e/webauthn-browser-probe.sh}" ;; + audit) E2E_CHILD="${E2E_CHILD:-$ROOT_DIR/scripts/e2e/browser-full-e2e-audit.sh}" ;; + *) E2E_CHILD="${E2E_CHILD:?unknown MODE (use probe|audit) and no E2E_CHILD override}" ;; +esac + +DEMO_SERVER_PID="" + +wait_for_url() { + local url="$1" + local attempts="${2:-60}" + for _ in $(seq 1 "$attempts"); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for $url" >&2 + return 1 +} + +start_demo_if_needed() { + local log_path="/tmp/passkey-kit-demo-smoke.log" + + if lsof -nP -iTCP:"$DEMO_PORT" -sTCP:LISTEN >/dev/null 2>&1; then + echo "Demo already running on port $DEMO_PORT (leaving it running)" + return 0 + fi + + echo "Starting demo on ${DEV_HOST}:${DEMO_PORT}" + ( + cd "$DEMO_DIR" + pnpm --ignore-workspace exec vite --host "$DEV_HOST" --port "$DEMO_PORT" --strictPort + ) >"$log_path" 2>&1 & + DEMO_SERVER_PID=$! + wait_for_url "$DEMO_URL" || { + echo "Demo did not come up; last log lines:" >&2 + tail -n 20 "$log_path" >&2 || true + return 1 + } +} + +cleanup() { + local exit_code=$? + if [[ -n "$DEMO_SERVER_PID" ]]; then + kill "$DEMO_SERVER_PID" >/dev/null 2>&1 || true + fi + agent-browser --session "$SESSION_NAME" close >/dev/null 2>&1 || true + exit "$exit_code" +} +trap cleanup EXIT + +start_demo_if_needed + +# Fresh session + first navigation, so the helper can resolve the CDP url and +# attach the authenticator to a live page. +agent-browser --session "$SESSION_NAME" open "$DEMO_URL" >/dev/null + +echo "Running child under a virtual WebAuthn authenticator: $E2E_CHILD" +node "$ROOT_DIR/scripts/e2e/agent-browser-webauthn-helper.mjs" run \ + --session "$SESSION_NAME" --url "$DEMO_URL" -- \ + env \ + SESSION_NAME="$SESSION_NAME" \ + DEMO_URL="$DEMO_URL" \ + PROBE_URL="$DEMO_URL" \ + RELAYER_PROXY_URL="${RELAYER_PROXY_URL:-}" \ + INDEXER_PROXY_URL="${INDEXER_PROXY_URL:-}" \ + RECIPIENT_ADDRESS="${RECIPIENT_ADDRESS:-}" \ + SKIP_ONCHAIN="${SKIP_ONCHAIN:-auto}" \ + bash "$E2E_CHILD" diff --git a/scripts/e2e/webauthn-browser-probe.sh b/scripts/e2e/webauthn-browser-probe.sh new file mode 100755 index 0000000..9ee68ff --- /dev/null +++ b/scripts/e2e/webauthn-browser-probe.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +# Isolated WebAuthn sanity probe: in a live agent-browser session (with a virtual +# authenticator attached by agent-browser-webauthn-helper.mjs), run a full +# navigator.credentials.create -> get -> crypto.subtle.verify round-trip and print +# the credential id + raw P-256 public key + verification result as JSON. +# +# This proves the CDP virtual authenticator + secp256r1 WebAuthn stack works +# before the full demo audit blames a real failure on the browser layer. +# +# Ported from smart-account-kit (project 34); generic. + +set -euo pipefail + +SESSION_NAME="${SESSION_NAME:?SESSION_NAME is required}" +PROBE_URL="${PROBE_URL:-http://localhost:5173}" + +agent-browser --session "$SESSION_NAME" open "$PROBE_URL" >/dev/null + +PROBE_JS="$(cat <<'JS' +(() => { + const toBase64Url = (bytes) => { + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + }; + + const toHex = (bytes) => + Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + + document.body.innerHTML = '
    ';
    +  window.__probeResult = null;
    +
    +  document.getElementById("probe").onclick = async () => {
    +    try {
    +      const registrationChallenge = crypto.getRandomValues(new Uint8Array(32));
    +      const userId = crypto.getRandomValues(new Uint8Array(16));
    +      const credential = await navigator.credentials.create({
    +        publicKey: {
    +          challenge: registrationChallenge,
    +          rp: { id: location.hostname, name: "Probe" },
    +          user: {
    +            id: userId,
    +            name: `probe-${Date.now()}`,
    +            displayName: "Probe User",
    +          },
    +          pubKeyCredParams: [{ alg: -7, type: "public-key" }],
    +          authenticatorSelection: {
    +            residentKey: "preferred",
    +            userVerification: "preferred",
    +          },
    +          timeout: 60000,
    +        },
    +      });
    +
    +      const spki = credential.response.getPublicKey();
    +      const publicKey = await crypto.subtle.importKey(
    +        "spki",
    +        spki,
    +        { name: "ECDSA", namedCurve: "P-256" },
    +        true,
    +        ["verify"]
    +      );
    +      const rawPublicKey = new Uint8Array(await crypto.subtle.exportKey("raw", publicKey));
    +
    +      const authChallenge = crypto.getRandomValues(new Uint8Array(32));
    +      const assertion = await navigator.credentials.get({
    +        publicKey: {
    +          challenge: authChallenge,
    +          rpId: location.hostname,
    +          allowCredentials: [{ id: credential.rawId, type: "public-key" }],
    +          userVerification: "preferred",
    +          timeout: 60000,
    +        },
    +      });
    +
    +      const authenticatorData = new Uint8Array(assertion.response.authenticatorData);
    +      const clientDataJSON = new Uint8Array(assertion.response.clientDataJSON);
    +      const signature = new Uint8Array(assertion.response.signature);
    +      const clientDataHash = new Uint8Array(await crypto.subtle.digest("SHA-256", clientDataJSON));
    +      const signedMessage = new Uint8Array(authenticatorData.length + clientDataHash.length);
    +      signedMessage.set(authenticatorData);
    +      signedMessage.set(clientDataHash, authenticatorData.length);
    +
    +      const verified = await crypto.subtle.verify(
    +        { name: "ECDSA", hash: "SHA-256" },
    +        publicKey,
    +        signature,
    +        signedMessage
    +      );
    +
    +      window.__probeResult = {
    +        credentialId: toBase64Url(new Uint8Array(credential.rawId)),
    +        rawPublicKeyHex: toHex(rawPublicKey),
    +        verified,
    +      };
    +      document.getElementById("result").textContent = JSON.stringify(window.__probeResult, null, 2);
    +    } catch (error) {
    +      window.__probeResult = {
    +        error: error instanceof Error ? error.message : String(error),
    +      };
    +      document.getElementById("result").textContent = JSON.stringify(window.__probeResult, null, 2);
    +    }
    +  };
    +
    +  return "ready";
    +})()
    +JS
    +)"
    +
    +agent-browser --session "$SESSION_NAME" eval "$PROBE_JS" >/dev/null
    +agent-browser --session "$SESSION_NAME" click "#probe" >/dev/null
    +
    +for _ in $(seq 1 60); do
    +  RESULT="$(agent-browser --session "$SESSION_NAME" eval "JSON.stringify(window.__probeResult)" | tr -d '\r')"
    +  if [[ "$RESULT" != "null" ]]; then
    +    printf '%s\n' "$RESULT"
    +    if printf '%s\n' "$RESULT" | grep -q '"verified":true'; then
    +      exit 0
    +    fi
    +    echo "Probe completed but verification did not pass" >&2
    +    exit 1
    +  fi
    +  sleep 1
    +done
    +
    +echo "Probe timed out" >&2
    +exit 1
    
    From 0563520401018a584edf482dd3d4216a5a4a035c Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sat, 11 Jul 2026 22:33:47 -0400
    Subject: [PATCH 17/30] =?UTF-8?q?fix(contracts):=20FIX-3b=20=E2=80=94=20cu?=
     =?UTF-8?q?mulative=20rolling-window=20allowance=20in=20sample-policy?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    sol re-verify (comment 2484) confirmed FIX-3's fail-open/admin gaps closed but
    kept it STILL-OPEN: Signature::Policy is secretless, so a per-transfer cap is
    trivially bypassed by repeating capped transfers to drain the wallet, and
    policy__ can't see co-signers — the only in-policy close is a cumulative budget.
    
    - sample-policy upgraded from a 10M per-transfer cap to a genuine cumulative
      rolling-window spending allowance: tracks Spend(wallet)={window_start, spent}
      in policy storage, resets after WINDOW_SECONDS (24h tumbling window), sums
      transfer amounts across the invocation's contexts and rejects once the
      window total would exceed WINDOW_ALLOWANCE (100M). checked_add on both the
      per-call total and the cumulative to fail closed on overflow.
    - Spend(wallet) TTL renewed alongside instance + Installed(wallet) so the
      counter can't archive-grief; uninstall also clears Spend.
    - Loud docs: interface policy__ gains a 'Value transfers with a SECRETLESS
      policy' section — value-moving policies are only safe as a cumulative/rate-
      limited allowance and/or paired via SignerLimits with an authenticated
      co-signer, never an unbounded per-transfer cap. Sample module doc rewritten.
    - New test sample_policy_cumulative_allowance: repeated sub-cap transfers are
      rejected once cumulative hits the cap, and the window reset restores spend.
    
    smart-wallet wasm unchanged (84924c53…); FIX-3b is sample-policy behavior +
    interface docs only. sample-policy wasm now e74af535….
    
    Co-Authored-By: Claude Fable 5 
    ---
     contracts/sample-policy/src/lib.rs            | 145 ++++++++++++++----
     contracts/smart-wallet-interface/src/lib.rs   |  17 ++
     .../src/tests/test_integration.rs             |  59 ++++++-
     3 files changed, 186 insertions(+), 35 deletions(-)
    
    diff --git a/contracts/sample-policy/src/lib.rs b/contracts/sample-policy/src/lib.rs
    index e5601e0..5dae647 100644
    --- a/contracts/sample-policy/src/lib.rs
    +++ b/contracts/sample-policy/src/lib.rs
    @@ -1,27 +1,41 @@
    -//! Sample policy demonstrating the smart wallet policy lifecycle SAFELY.
    +//! Sample policy: a CUMULATIVE rolling-window spending allowance.
     //!
     //! It is a reference others copy, so it models every hardening the audit
    -//! asked for:
    +//! asked for and — critically — is safe even though `Signature::Policy`
    +//! carries no secret (anyone can submit it):
     //!
    +//! - **Cumulative allowance (FIX-3b).** A per-transfer cap is NOT a spending
    +//!   limit: because policy signatures are secretless, a per-transfer cap is
    +//!   trivially bypassed by repeating capped transfers to drain the wallet.
    +//!   This policy instead tracks cumulative spend per wallet within a rolling
    +//!   time window and rejects once the window's total would exceed the cap, so
    +//!   the most anyone can move through it is `WINDOW_ALLOWANCE` per
    +//!   `WINDOW_SECONDS` — a genuine, bounded rate limit.
     //! - **Caller authentication (FIX-2).** `policy__` is publicly callable with
     //!   an attacker-chosen `source`. Because this policy keeps per-wallet state,
     //!   it calls `source.require_auth()` first. During a real `__check_auth` the
     //!   wallet is the direct invoker of `policy__`, so invoker auth satisfies
     //!   this; an external caller cannot satisfy it for a wallet it does not
    -//!   control. The `Installed(source)` marker is a secondary gate (it proves
    -//!   past installation, never current caller identity).
    +//!   control.
     //! - **Deny-by-default (FIX-3).** Every context is rejected unless it is a
    -//!   `transfer` within the cap. Any other function, any non-contract context,
    -//!   a missing/mistyped amount argument, or a context targeting the wallet's
    -//!   own admin surface all FAIL CLOSED.
    +//!   `transfer` of a positive amount to a contract other than the wallet.
    +//!   Any other function, any non-contract context, a missing/mistyped amount,
    +//!   a non-positive amount, or a context targeting the wallet's own admin
    +//!   surface all FAIL CLOSED.
     //! - **TTL renewal (FIX-4).** `install` and every successful `policy__`
    -//!   extend this policy's instance/code TTL and the `Installed(wallet)` key,
    -//!   so a policy participating in a wallet's authorization cannot silently
    +//!   extend this policy's instance/code TTL and the per-wallet state keys, so
    +//!   a policy participating in a wallet's authorization cannot silently
     //!   archive into a wallet lock.
     //! - **Permissionless self-clean (FIX-1).** The wallet does NOT call
    -//!   `uninstall` on removal. Anyone may call it; it clears `Installed(wallet)`
    +//!   `uninstall` on removal. Anyone may call it; it clears per-wallet state
     //!   only after confirming (via the wallet's own `get_signer`) that this
     //!   policy is genuinely no longer a signer on that wallet.
    +//!
    +//! Even with a cumulative allowance, a secretless value-moving policy should
    +//! generally ALSO be paired — via the granting signer's `SignerLimits` — with
    +//! an authenticated cryptographic co-signer, so that the bounded amount still
    +//! requires a real signature to move. The allowance bounds worst-case loss;
    +//! the co-signer removes the "anyone" from "anyone can spend up to the cap".
     
     #![no_std]
     
    @@ -36,7 +50,8 @@ use soroban_sdk::{
     #[derive(Copy, Clone, Debug, Eq, PartialEq)]
     #[repr(u32)]
     pub enum PolicyError {
    -    /// A context is not permitted (deny-by-default).
    +    /// A context is not permitted (deny-by-default), or the cumulative
    +    /// window allowance would be exceeded.
         NotAllowed = 1,
         /// `policy__` was called for a wallet that never installed this policy.
         NotInstalled = 2,
    @@ -45,8 +60,13 @@ pub enum PolicyError {
         StillInstalled = 3,
     }
     
    -/// Maximum per-`transfer` amount this policy permits.
    -const TRANSFER_LIMIT: i128 = 10_000_000;
    +/// Cumulative amount this policy will authorize for a wallet within one
    +/// window.
    +const WINDOW_ALLOWANCE: i128 = 100_000_000;
    +/// Rolling-window length in seconds (24h). A tumbling window: the first spend
    +/// after `WINDOW_SECONDS` have elapsed since the window started resets the
    +/// counter and begins a new window.
    +const WINDOW_SECONDS: u64 = 60 * 60 * 24;
     
     /// TTL renewal parameters (in ledgers at the historical 5s close time): bump
     /// to ~30 days whenever remaining TTL drops below ~1 week. Both are well under
    @@ -58,6 +78,15 @@ const RENEW_TO: u32 = 60 * 60 * 24 / 5 * 30;
     #[derive(Clone, Debug, Eq, PartialEq)]
     pub enum StorageKey {
         Installed(Address),
    +    Spend(Address),
    +}
    +
    +/// Per-wallet cumulative-spend accounting for the current window.
    +#[contracttype]
    +#[derive(Clone, Debug, Eq, PartialEq)]
    +pub struct Allowance {
    +    pub window_start: u64,
    +    pub spent: i128,
     }
     
     #[contract]
    @@ -69,18 +98,19 @@ impl PolicyInterface for Contract {
             // The wallet is the direct invoker during add_signer; invoker auth.
             wallet.require_auth();
     
    -        let key = StorageKey::Installed(wallet);
    +        let installed_key = StorageKey::Installed(wallet);
             env.storage()
                 .persistent()
    -            .set::(&key, &true);
    +            .set::(&installed_key, &true);
     
    -        renew_ttls(&env, &key);
    +        renew_instance(&env);
    +        renew_persistent(&env, &installed_key);
         }
     
         fn uninstall(env: Env, wallet: Address) {
    -        // Permissionless (FIX-1): clear install-state only once this policy is
    -        // genuinely no longer a signer on `wallet`. The wallet's get_signer is
    -        // a read-only view; a griefer cannot clear state for a wallet where
    +        // Permissionless (FIX-1): clear per-wallet state only once this policy
    +        // is genuinely no longer a signer on `wallet`. The wallet's get_signer
    +        // is a read-only view; a griefer cannot clear state for a wallet where
             // this policy is still installed.
             let still_signer = SmartWalletClient::new(&env, &wallet)
                 .get_signer(&SignerKey::Policy(env.current_contract_address()))
    @@ -92,7 +122,10 @@ impl PolicyInterface for Contract {
     
             env.storage()
                 .persistent()
    -            .remove::(&StorageKey::Installed(wallet));
    +            .remove::(&StorageKey::Installed(wallet.clone()));
    +        env.storage()
    +            .persistent()
    +            .remove::(&StorageKey::Spend(wallet));
         }
     
         fn policy__(env: Env, source: Address, _signer: SignerKey, contexts: Vec) {
    @@ -100,16 +133,14 @@ impl PolicyInterface for Contract {
             // any per-wallet state. Satisfied by invoker auth during __check_auth.
             source.require_auth();
     
    -        let key = StorageKey::Installed(source.clone());
    -        if !env.storage().persistent().has::(&key) {
    +        let installed_key = StorageKey::Installed(source.clone());
    +        if !env.storage().persistent().has::(&installed_key) {
                 panic_with_error!(&env, PolicyError::NotInstalled);
             }
     
    -        // FIX-4: keep this policy and its install marker alive for as long as
    -        // it is actively authorizing.
    -        renew_ttls(&env, &key);
    -
    -        // FIX-3: deny-by-default. Anything not explicitly permitted rejects.
    +        // FIX-3: deny-by-default. Sum the transfer amounts across all contexts
    +        // in this invocation; anything not explicitly permitted rejects.
    +        let mut total: i128 = 0;
             for context in contexts.iter() {
                 match context {
                     Context::Contract(ContractContext {
    @@ -117,9 +148,8 @@ impl PolicyInterface for Contract {
                         fn_name,
                         args,
                     }) => {
    -                    // Never independently authorize the wallet's own admin
    -                    // surface (add/update/remove/upgrade). `source` is the
    -                    // wallet.
    +                    // Never authorize the wallet's own admin surface
    +                    // (add/update/remove/upgrade). `source` is the wallet.
                         if contract == source {
                             panic_with_error!(&env, PolicyError::NotAllowed);
                         }
    @@ -130,28 +160,75 @@ impl PolicyInterface for Contract {
                         }
     
                         // Fail closed if the amount argument is missing or not an
    -                    // i128 (previously this fell open).
    +                    // i128.
                         let amount = match args.get(2).and_then(|v| i128::try_from_val(&env, &v).ok()) {
                             Some(amount) => amount,
                             None => panic_with_error!(&env, PolicyError::NotAllowed),
                         };
     
    -                    if amount <= 0 || amount > TRANSFER_LIMIT {
    +                    if amount <= 0 {
                             panic_with_error!(&env, PolicyError::NotAllowed);
                         }
    +
    +                    total = match total.checked_add(amount) {
    +                        Some(total) => total,
    +                        None => panic_with_error!(&env, PolicyError::NotAllowed),
    +                    };
                     }
                     // Non-contract contexts (deploys, etc.) are never permitted.
                     _ => panic_with_error!(&env, PolicyError::NotAllowed),
                 }
             }
    +
    +        // FIX-3b: cumulative rolling-window allowance. Load the wallet's spend
    +        // record, resetting it if the window has elapsed, and reject if this
    +        // invocation would push cumulative spend over the cap.
    +        let now = env.ledger().timestamp();
    +        let spend_key = StorageKey::Spend(source.clone());
    +        let mut allowance = env
    +            .storage()
    +            .persistent()
    +            .get::(&spend_key)
    +            .unwrap_or(Allowance {
    +                window_start: now,
    +                spent: 0,
    +            });
    +
    +        if now.saturating_sub(allowance.window_start) >= WINDOW_SECONDS {
    +            allowance.window_start = now;
    +            allowance.spent = 0;
    +        }
    +
    +        let new_spent = match allowance.spent.checked_add(total) {
    +            Some(new_spent) => new_spent,
    +            None => panic_with_error!(&env, PolicyError::NotAllowed),
    +        };
    +
    +        if new_spent > WINDOW_ALLOWANCE {
    +            panic_with_error!(&env, PolicyError::NotAllowed);
    +        }
    +
    +        allowance.spent = new_spent;
    +        env.storage()
    +            .persistent()
    +            .set::(&spend_key, &allowance);
    +
    +        // FIX-4: keep this policy and its per-wallet state alive for as long
    +        // as it is actively authorizing.
    +        renew_instance(&env);
    +        renew_persistent(&env, &installed_key);
    +        renew_persistent(&env, &spend_key);
         }
     }
     
    -fn renew_ttls(env: &Env, installed_key: &StorageKey) {
    +fn renew_instance(env: &Env) {
         env.storage()
             .instance()
             .extend_ttl(RENEW_THRESHOLD, RENEW_TO);
    +}
    +
    +fn renew_persistent(env: &Env, key: &StorageKey) {
         env.storage()
             .persistent()
    -        .extend_ttl::(installed_key, RENEW_THRESHOLD, RENEW_TO);
    +        .extend_ttl::(key, RENEW_THRESHOLD, RENEW_TO);
     }
    diff --git a/contracts/smart-wallet-interface/src/lib.rs b/contracts/smart-wallet-interface/src/lib.rs
    index 5d8b02e..d46a42b 100644
    --- a/contracts/smart-wallet-interface/src/lib.rs
    +++ b/contracts/smart-wallet-interface/src/lib.rs
    @@ -69,6 +69,23 @@ pub trait PolicyInterface {
         /// host ScVal order, so a stateful policy used inside limits can observe
         /// order-dependent invocation. A read-only (or idempotent) policy is
         /// immune to this.
    +    ///
    +    /// ## Value transfers with a SECRETLESS policy (audit FIX-3b)
    +    ///
    +    /// `Signature::Policy` carries NO secret — anyone can submit it, so a
    +    /// policy that authorizes value transfers is authorizing them for
    +    /// EVERYONE. A per-transfer cap is therefore NOT a spending limit: it is
    +    /// trivially bypassed by repeating capped transfers to drain the wallet.
    +    /// A value-moving policy is only safe when it is one (ideally both) of:
    +    ///
    +    /// - a CUMULATIVE / rate-limited allowance that bounds total spend over a
    +    ///   window (so worst-case loss is bounded — see `sample-policy`), and/or
    +    /// - paired, via the granting signer's `SignerLimits`, with an
    +    ///   authenticated cryptographic co-signer, so the bounded amount still
    +    ///   requires a real signature to move.
    +    ///
    +    /// Never ship a policy that authorizes transfers under only an
    +    /// unbounded per-transfer cap.
         fn policy__(env: Env, source: Address, signer: SignerKey, contexts: Vec);
         /// Lifecycle hook invoked by a wallet when this policy is added as a
         /// signer (from `add_signer`/`__constructor`). The wallet is the direct
    diff --git a/contracts/smart-wallet/src/tests/test_integration.rs b/contracts/smart-wallet/src/tests/test_integration.rs
    index 18a75bf..edcc749 100644
    --- a/contracts/smart-wallet/src/tests/test_integration.rs
    +++ b/contracts/smart-wallet/src/tests/test_integration.rs
    @@ -13,7 +13,7 @@ use smart_wallet_interface::types::{
     };
     use soroban_sdk::{
         map,
    -    testutils::Address as _,
    +    testutils::{Address as _, Ledger as _},
         token,
         xdr::{
             InvokeContractArgs, SorobanAddressCredentials, SorobanAuthorizationEntry,
    @@ -275,3 +275,60 @@ fn sample_policy_rejects_spoofed_caller() {
             "spoofed policy__ caller must be rejected by source.require_auth()"
         );
     }
    +
    +/// FIX-3b: the sample policy is a CUMULATIVE rolling-window allowance, not a
    +/// per-transfer cap. Repeated sub-cap transfers are rejected once the window's
    +/// cumulative spend would exceed the cap — closing the secretless repeat-drain
    +/// — and the counter resets after the window elapses.
    +#[test]
    +fn sample_policy_cumulative_allowance() {
    +    let env = test_env();
    +    env.ledger().set_timestamp(1_000_000);
    +
    +    let signer = Ed25519Signer::new(51);
    +    let (wallet, wallet_client) = register_wallet(
    +        &env,
    +        &signer.signer(
    +            &env,
    +            SignerExpiration(None),
    +            SignerLimits(None),
    +            SignerStorage::Persistent,
    +        ),
    +    );
    +
    +    let policy = env.register(PolicyContract, ());
    +    let policy_client = PolicyContractClient::new(&env, &policy);
    +    let policy_key = SignerKey::Policy(policy.clone());
    +
    +    wallet_client.mock_all_auths().add_signer(&Signer::Policy(
    +        policy.clone(),
    +        SignerExpiration(None),
    +        SignerLimits(None),
    +        SignerStorage::Temporary,
    +    ));
    +
    +    let token = Address::generate(&env);
    +
    +    // Authorize a single transfer of `amount` (mock_all_auths satisfies the
    +    // policy's source.require_auth()).
    +    let authorize = |amount: i128| {
    +        policy_client.mock_all_auths().try_policy__(
    +            &wallet,
    +            &policy_key,
    +            &soroban_sdk::vec![&env, transfer_context(&env, &token, &wallet, amount)],
    +        )
    +    };
    +
    +    // The cumulative window cap is 100_000_000. Spend up to it in chunks.
    +    assert!(authorize(60_000_000).is_ok());
    +    assert!(authorize(40_000_000).is_ok()); // cumulative now == cap
    +
    +    // A tiny sub-cap transfer is now rejected: the repeat-drain is closed.
    +    assert!(authorize(1).is_err());
    +
    +    // After the window elapses, the counter resets and spending resumes.
    +    env.ledger().set_timestamp(1_000_000 + 60 * 60 * 24 + 1);
    +    assert!(authorize(70_000_000).is_ok());
    +    assert!(authorize(30_000_000).is_ok()); // cumulative == cap again
    +    assert!(authorize(1).is_err());
    +}
    
    From 6581e007e8d0970f21a71a6e477c9740630a1979 Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sat, 11 Jul 2026 22:40:28 -0400
    Subject: [PATCH 18/30] C2 services: relayer-proxy CF Worker (keyless per-IP
     key minting) + demo Pages config
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    New top-level relayer-proxy/ Cloudflare Worker, adapted from the smart-account-kit
    reference (audit #598/#600 §ADAPT). Fixes passkey-kit's secrets-in-bundle defect.
    
    - Keyless per-IP Relayer API-key minting: GET {RELAYER_BASE_URL}/gen, cached in
      KV under api-key: (CF-Connecting-IP -> X-Forwarded-For -> X-Real-IP), so
      the browser submits with ZERO secrets.
    - POST / accepts {func,auth} -> submitSorobanTransaction OR {xdr} ->
      submitTransaction (plugin ^0.20); exactly one required.
    - Error mapping: PluginExecutionError -> 400 (code/details), PluginTransportError
      -> its status, malformed JSON -> 400, else -> 500. GET /status, GET / health.
    - Testnet Friendbot retry loop for missing channel accounts (5 min); mainnet
      never retries. [env.production] mainnet block. Real wrangler.toml (placeholder
      KV ids to provision at deploy). NO deploys from this todo.
    - vitest suite: 20 tests, all green (IP extraction, mode validation, both submit
      paths, 3 error mappings, testnet/mainnet retry, per-IP key lifecycle incl.
      legacy-format migration). tsc typecheck clean.
    
    Demo Pages deploy gap (audit #597: "broken-as-scripted, no wrangler.toml"):
    - Root wrangler.toml with name=passkey-kit-demo + pages_build_output_dir=demo/dist
      so the existing root `deploy:demo` (wrangler pages deploy, run from repo root)
      resolves project + assets with no flags. deploy:demo:prod's `--env production`
      is invalid for `pages deploy` (no such flag; Pages uses --branch) — that's a
      root package.json fix owned by the demo agent; documented in-config + on todo.
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     relayer-proxy/.dev.vars.example         |    7 +
     relayer-proxy/.gitignore                |    7 +
     relayer-proxy/README.md                 |   80 +
     relayer-proxy/package.json              |   23 +
     relayer-proxy/pnpm-lock.yaml            | 2279 +++++++++++++++++++++++
     relayer-proxy/src/constants.ts          |   63 +
     relayer-proxy/src/index.test.ts         |  513 +++++
     relayer-proxy/src/index.ts              |  528 ++++++
     relayer-proxy/tsconfig.json             |   13 +
     relayer-proxy/worker-configuration.d.ts |   13 +
     relayer-proxy/wrangler.toml             |   40 +
     wrangler.toml                           |   24 +
     12 files changed, 3590 insertions(+)
     create mode 100644 relayer-proxy/.dev.vars.example
     create mode 100644 relayer-proxy/.gitignore
     create mode 100644 relayer-proxy/README.md
     create mode 100644 relayer-proxy/package.json
     create mode 100644 relayer-proxy/pnpm-lock.yaml
     create mode 100644 relayer-proxy/src/constants.ts
     create mode 100644 relayer-proxy/src/index.test.ts
     create mode 100644 relayer-proxy/src/index.ts
     create mode 100644 relayer-proxy/tsconfig.json
     create mode 100644 relayer-proxy/worker-configuration.d.ts
     create mode 100644 relayer-proxy/wrangler.toml
     create mode 100644 wrangler.toml
    
    diff --git a/relayer-proxy/.dev.vars.example b/relayer-proxy/.dev.vars.example
    new file mode 100644
    index 0000000..8628bc3
    --- /dev/null
    +++ b/relayer-proxy/.dev.vars.example
    @@ -0,0 +1,7 @@
    +# passkey-kit relayer-proxy local overrides.
    +# Copy to .dev.vars only if you want to override the wrangler.toml [vars]
    +# defaults during `wrangler dev`. No secrets are required — the worker mints
    +# Relayer API keys itself via the keyless /gen endpoint.
    +
    +NETWORK="testnet"
    +RELAYER_BASE_URL="https://channels.openzeppelin.com/testnet"
    diff --git a/relayer-proxy/.gitignore b/relayer-proxy/.gitignore
    new file mode 100644
    index 0000000..9a16be9
    --- /dev/null
    +++ b/relayer-proxy/.gitignore
    @@ -0,0 +1,7 @@
    +node_modules/
    +dist/
    +.wrangler/
    +.dev.vars
    +.dev.vars.*
    +!.dev.vars.example
    +*.log
    diff --git a/relayer-proxy/README.md b/relayer-proxy/README.md
    new file mode 100644
    index 0000000..e078b97
    --- /dev/null
    +++ b/relayer-proxy/README.md
    @@ -0,0 +1,80 @@
    +# passkey-kit relayer-proxy
    +
    +A Cloudflare Worker that fronts the [OpenZeppelin Relayer Channels](https://docs.openzeppelin.com/relayer/stellar)
    +service so the browser can submit fee-sponsored transactions with **zero
    +secrets in the bundle**. It mints and caches one Relayer API key per client IP,
    +so `PasskeyKit`/`PasskeyServer` never ship a relayer key to the client.
    +
    +This replaces passkey-kit's old defect of inlining `VITE_relayerApiKey` into the
    +demo's client JS (audit #597/#598).
    +
    +## How it works
    +
    +- **Keyless per-IP key minting.** On the first request from an IP the worker
    +  calls the Relayer's unauthenticated `GET {RELAYER_BASE_URL}/gen` endpoint,
    +  and caches the returned key in KV under `api-key:` (indefinitely — the
    +  Relayer resets usage limits every 24h on its side). Client IP is taken from
    +  `CF-Connecting-IP`, then `X-Forwarded-For`, then `X-Real-IP`.
    +- **Two submission modes** (`POST /`):
    +  - `{ func, auth }` → `submitSorobanTransaction` — Relayer builds the tx with
    +    channel accounts (Address credentials: transfers, wallet ops).
    +  - `{ xdr }` → `submitTransaction` — Relayer fee-bumps an already-signed tx
    +    (source-account auth: deploys).
    +  Exactly one of the two must be present (400 otherwise).
    +- **Error mapping**: `PluginExecutionError` → 400 `{error, data:{code,details}}`;
    +  `PluginTransportError` → its `statusCode`; malformed JSON → 400; anything else
    +  → 500. Success → `{ success: true, data: { transactionId, hash, status } }`.
    +- **Testnet Friendbot retry**: if the Relayer reports a missing channel account
    +  (after a testnet reset), the worker funds it via Friendbot and retries for up
    +  to 5 minutes. Mainnet never retries (no Friendbot).
    +
    +## Endpoints
    +
    +| Method | Path | Purpose |
    +|---|---|---|
    +| GET | `/` | health: `{ status, service, network }` |
    +| POST | `/` | submit: `{ func, auth } \| { xdr }` |
    +| GET | `/status` | client IP, network, whether a key is cached |
    +
    +## Config
    +
    +`wrangler.toml`:
    +
    +- Testnet (default): `NETWORK=testnet`, `RELAYER_BASE_URL=https://channels.openzeppelin.com/testnet`.
    +- Mainnet (`[env.production]`): `NETWORK=mainnet`, `RELAYER_BASE_URL=https://channels.openzeppelin.com`.
    +- KV binding `API_KEYS` (one namespace per network). The committed ids are
    +  placeholders — provision real ones before deploying:
    +
    +```sh
    +wrangler kv namespace create API_KEYS            # testnet id -> [[kv_namespaces]]
    +wrangler kv namespace create API_KEYS            # mainnet id -> [[env.production.kv_namespaces]]
    +```
    +
    +No secrets are needed; the worker mints Relayer keys itself.
    +
    +## Develop / test
    +
    +```sh
    +cd relayer-proxy
    +pnpm install --ignore-workspace   # or: npm install
    +pnpm test                          # vitest — worker unit tests
    +pnpm typecheck                     # tsc --noEmit
    +pnpm dev                           # wrangler dev (local)
    +```
    +
    +The tests mock `@openzeppelin/relayer-plugin-channels` and KV, and cover: IP
    +extraction, mode validation, both submission paths, the three error mappings,
    +testnet-vs-mainnet retry behavior, and the per-IP key lifecycle (mint / reuse /
    +legacy-format migration).
    +
    +## Deploy
    +
    +> Deploys are orchestrator-gated (not run from the services todo).
    +
    +```sh
    +pnpm deploy               # testnet: wrangler deploy
    +pnpm deploy:production    # mainnet: wrangler deploy --env production
    +```
    +
    +Relayer keys for live verification can be generated at
    +`{RELAYER_BASE_URL}/gen` (testnet: `https://channels.openzeppelin.com/testnet/gen`).
    diff --git a/relayer-proxy/package.json b/relayer-proxy/package.json
    new file mode 100644
    index 0000000..c65542e
    --- /dev/null
    +++ b/relayer-proxy/package.json
    @@ -0,0 +1,23 @@
    +{
    +  "name": "passkey-kit-relayer-proxy",
    +  "version": "1.0.0",
    +  "private": true,
    +  "type": "module",
    +  "scripts": {
    +    "dev": "wrangler dev",
    +    "deploy": "wrangler deploy",
    +    "deploy:production": "wrangler deploy --env production",
    +    "test": "vitest run",
    +    "typecheck": "tsc --noEmit"
    +  },
    +  "dependencies": {
    +    "@openzeppelin/relayer-plugin-channels": "^0.20.0",
    +    "hono": "^4.12.11"
    +  },
    +  "devDependencies": {
    +    "@cloudflare/workers-types": "^4.20241202.0",
    +    "typescript": "^5.7.2",
    +    "vitest": "^2.1.8",
    +    "wrangler": "^4.80.0"
    +  }
    +}
    diff --git a/relayer-proxy/pnpm-lock.yaml b/relayer-proxy/pnpm-lock.yaml
    new file mode 100644
    index 0000000..ee99738
    --- /dev/null
    +++ b/relayer-proxy/pnpm-lock.yaml
    @@ -0,0 +1,2279 @@
    +lockfileVersion: '9.0'
    +
    +settings:
    +  autoInstallPeers: true
    +  excludeLinksFromLockfile: false
    +
    +importers:
    +
    +  .:
    +    dependencies:
    +      '@openzeppelin/relayer-plugin-channels':
    +        specifier: ^0.20.0
    +        version: 0.20.0
    +      hono:
    +        specifier: ^4.12.11
    +        version: 4.12.29
    +    devDependencies:
    +      '@cloudflare/workers-types':
    +        specifier: ^4.20241202.0
    +        version: 4.20260702.1
    +      typescript:
    +        specifier: ^5.7.2
    +        version: 5.9.3
    +      vitest:
    +        specifier: ^2.1.8
    +        version: 2.1.9
    +      wrangler:
    +        specifier: ^4.80.0
    +        version: 4.110.0(@cloudflare/workers-types@4.20260702.1)
    +
    +packages:
    +
    +  '@actions/exec@1.1.1':
    +    resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==}
    +
    +  '@actions/io@1.1.3':
    +    resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==}
    +
    +  '@cloudflare/kv-asset-handler@0.5.0':
    +    resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
    +    engines: {node: '>=22.0.0'}
    +
    +  '@cloudflare/unenv-preset@2.16.1':
    +    resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
    +    peerDependencies:
    +      unenv: 2.0.0-rc.24
    +      workerd: '>1.20260305.0 <2.0.0-0'
    +    peerDependenciesMeta:
    +      workerd:
    +        optional: true
    +
    +  '@cloudflare/workerd-darwin-64@1.20260708.1':
    +    resolution: {integrity: sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg==}
    +    engines: {node: '>=16'}
    +    cpu: [x64]
    +    os: [darwin]
    +
    +  '@cloudflare/workerd-darwin-arm64@1.20260708.1':
    +    resolution: {integrity: sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA==}
    +    engines: {node: '>=16'}
    +    cpu: [arm64]
    +    os: [darwin]
    +
    +  '@cloudflare/workerd-linux-64@1.20260708.1':
    +    resolution: {integrity: sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA==}
    +    engines: {node: '>=16'}
    +    cpu: [x64]
    +    os: [linux]
    +
    +  '@cloudflare/workerd-linux-arm64@1.20260708.1':
    +    resolution: {integrity: sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA==}
    +    engines: {node: '>=16'}
    +    cpu: [arm64]
    +    os: [linux]
    +
    +  '@cloudflare/workerd-windows-64@1.20260708.1':
    +    resolution: {integrity: sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA==}
    +    engines: {node: '>=16'}
    +    cpu: [x64]
    +    os: [win32]
    +
    +  '@cloudflare/workers-types@4.20260702.1':
    +    resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==}
    +
    +  '@cspotcode/source-map-support@0.8.1':
    +    resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
    +    engines: {node: '>=12'}
    +
    +  '@emnapi/runtime@1.11.2':
    +    resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
    +
    +  '@esbuild/aix-ppc64@0.21.5':
    +    resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
    +    engines: {node: '>=12'}
    +    cpu: [ppc64]
    +    os: [aix]
    +
    +  '@esbuild/aix-ppc64@0.28.1':
    +    resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
    +    engines: {node: '>=18'}
    +    cpu: [ppc64]
    +    os: [aix]
    +
    +  '@esbuild/android-arm64@0.21.5':
    +    resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
    +    engines: {node: '>=12'}
    +    cpu: [arm64]
    +    os: [android]
    +
    +  '@esbuild/android-arm64@0.28.1':
    +    resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
    +    engines: {node: '>=18'}
    +    cpu: [arm64]
    +    os: [android]
    +
    +  '@esbuild/android-arm@0.21.5':
    +    resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
    +    engines: {node: '>=12'}
    +    cpu: [arm]
    +    os: [android]
    +
    +  '@esbuild/android-arm@0.28.1':
    +    resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
    +    engines: {node: '>=18'}
    +    cpu: [arm]
    +    os: [android]
    +
    +  '@esbuild/android-x64@0.21.5':
    +    resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
    +    engines: {node: '>=12'}
    +    cpu: [x64]
    +    os: [android]
    +
    +  '@esbuild/android-x64@0.28.1':
    +    resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
    +    engines: {node: '>=18'}
    +    cpu: [x64]
    +    os: [android]
    +
    +  '@esbuild/darwin-arm64@0.21.5':
    +    resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
    +    engines: {node: '>=12'}
    +    cpu: [arm64]
    +    os: [darwin]
    +
    +  '@esbuild/darwin-arm64@0.28.1':
    +    resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
    +    engines: {node: '>=18'}
    +    cpu: [arm64]
    +    os: [darwin]
    +
    +  '@esbuild/darwin-x64@0.21.5':
    +    resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
    +    engines: {node: '>=12'}
    +    cpu: [x64]
    +    os: [darwin]
    +
    +  '@esbuild/darwin-x64@0.28.1':
    +    resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
    +    engines: {node: '>=18'}
    +    cpu: [x64]
    +    os: [darwin]
    +
    +  '@esbuild/freebsd-arm64@0.21.5':
    +    resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
    +    engines: {node: '>=12'}
    +    cpu: [arm64]
    +    os: [freebsd]
    +
    +  '@esbuild/freebsd-arm64@0.28.1':
    +    resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
    +    engines: {node: '>=18'}
    +    cpu: [arm64]
    +    os: [freebsd]
    +
    +  '@esbuild/freebsd-x64@0.21.5':
    +    resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
    +    engines: {node: '>=12'}
    +    cpu: [x64]
    +    os: [freebsd]
    +
    +  '@esbuild/freebsd-x64@0.28.1':
    +    resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
    +    engines: {node: '>=18'}
    +    cpu: [x64]
    +    os: [freebsd]
    +
    +  '@esbuild/linux-arm64@0.21.5':
    +    resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
    +    engines: {node: '>=12'}
    +    cpu: [arm64]
    +    os: [linux]
    +
    +  '@esbuild/linux-arm64@0.28.1':
    +    resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
    +    engines: {node: '>=18'}
    +    cpu: [arm64]
    +    os: [linux]
    +
    +  '@esbuild/linux-arm@0.21.5':
    +    resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
    +    engines: {node: '>=12'}
    +    cpu: [arm]
    +    os: [linux]
    +
    +  '@esbuild/linux-arm@0.28.1':
    +    resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
    +    engines: {node: '>=18'}
    +    cpu: [arm]
    +    os: [linux]
    +
    +  '@esbuild/linux-ia32@0.21.5':
    +    resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
    +    engines: {node: '>=12'}
    +    cpu: [ia32]
    +    os: [linux]
    +
    +  '@esbuild/linux-ia32@0.28.1':
    +    resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
    +    engines: {node: '>=18'}
    +    cpu: [ia32]
    +    os: [linux]
    +
    +  '@esbuild/linux-loong64@0.21.5':
    +    resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
    +    engines: {node: '>=12'}
    +    cpu: [loong64]
    +    os: [linux]
    +
    +  '@esbuild/linux-loong64@0.28.1':
    +    resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
    +    engines: {node: '>=18'}
    +    cpu: [loong64]
    +    os: [linux]
    +
    +  '@esbuild/linux-mips64el@0.21.5':
    +    resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
    +    engines: {node: '>=12'}
    +    cpu: [mips64el]
    +    os: [linux]
    +
    +  '@esbuild/linux-mips64el@0.28.1':
    +    resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
    +    engines: {node: '>=18'}
    +    cpu: [mips64el]
    +    os: [linux]
    +
    +  '@esbuild/linux-ppc64@0.21.5':
    +    resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
    +    engines: {node: '>=12'}
    +    cpu: [ppc64]
    +    os: [linux]
    +
    +  '@esbuild/linux-ppc64@0.28.1':
    +    resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
    +    engines: {node: '>=18'}
    +    cpu: [ppc64]
    +    os: [linux]
    +
    +  '@esbuild/linux-riscv64@0.21.5':
    +    resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
    +    engines: {node: '>=12'}
    +    cpu: [riscv64]
    +    os: [linux]
    +
    +  '@esbuild/linux-riscv64@0.28.1':
    +    resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
    +    engines: {node: '>=18'}
    +    cpu: [riscv64]
    +    os: [linux]
    +
    +  '@esbuild/linux-s390x@0.21.5':
    +    resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
    +    engines: {node: '>=12'}
    +    cpu: [s390x]
    +    os: [linux]
    +
    +  '@esbuild/linux-s390x@0.28.1':
    +    resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
    +    engines: {node: '>=18'}
    +    cpu: [s390x]
    +    os: [linux]
    +
    +  '@esbuild/linux-x64@0.21.5':
    +    resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
    +    engines: {node: '>=12'}
    +    cpu: [x64]
    +    os: [linux]
    +
    +  '@esbuild/linux-x64@0.28.1':
    +    resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
    +    engines: {node: '>=18'}
    +    cpu: [x64]
    +    os: [linux]
    +
    +  '@esbuild/netbsd-arm64@0.28.1':
    +    resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
    +    engines: {node: '>=18'}
    +    cpu: [arm64]
    +    os: [netbsd]
    +
    +  '@esbuild/netbsd-x64@0.21.5':
    +    resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
    +    engines: {node: '>=12'}
    +    cpu: [x64]
    +    os: [netbsd]
    +
    +  '@esbuild/netbsd-x64@0.28.1':
    +    resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
    +    engines: {node: '>=18'}
    +    cpu: [x64]
    +    os: [netbsd]
    +
    +  '@esbuild/openbsd-arm64@0.28.1':
    +    resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
    +    engines: {node: '>=18'}
    +    cpu: [arm64]
    +    os: [openbsd]
    +
    +  '@esbuild/openbsd-x64@0.21.5':
    +    resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
    +    engines: {node: '>=12'}
    +    cpu: [x64]
    +    os: [openbsd]
    +
    +  '@esbuild/openbsd-x64@0.28.1':
    +    resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
    +    engines: {node: '>=18'}
    +    cpu: [x64]
    +    os: [openbsd]
    +
    +  '@esbuild/openharmony-arm64@0.28.1':
    +    resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
    +    engines: {node: '>=18'}
    +    cpu: [arm64]
    +    os: [openharmony]
    +
    +  '@esbuild/sunos-x64@0.21.5':
    +    resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
    +    engines: {node: '>=12'}
    +    cpu: [x64]
    +    os: [sunos]
    +
    +  '@esbuild/sunos-x64@0.28.1':
    +    resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
    +    engines: {node: '>=18'}
    +    cpu: [x64]
    +    os: [sunos]
    +
    +  '@esbuild/win32-arm64@0.21.5':
    +    resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
    +    engines: {node: '>=12'}
    +    cpu: [arm64]
    +    os: [win32]
    +
    +  '@esbuild/win32-arm64@0.28.1':
    +    resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
    +    engines: {node: '>=18'}
    +    cpu: [arm64]
    +    os: [win32]
    +
    +  '@esbuild/win32-ia32@0.21.5':
    +    resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
    +    engines: {node: '>=12'}
    +    cpu: [ia32]
    +    os: [win32]
    +
    +  '@esbuild/win32-ia32@0.28.1':
    +    resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
    +    engines: {node: '>=18'}
    +    cpu: [ia32]
    +    os: [win32]
    +
    +  '@esbuild/win32-x64@0.21.5':
    +    resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
    +    engines: {node: '>=12'}
    +    cpu: [x64]
    +    os: [win32]
    +
    +  '@esbuild/win32-x64@0.28.1':
    +    resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
    +    engines: {node: '>=18'}
    +    cpu: [x64]
    +    os: [win32]
    +
    +  '@img/colour@1.1.0':
    +    resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
    +    engines: {node: '>=18'}
    +
    +  '@img/sharp-darwin-arm64@0.34.5':
    +    resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [arm64]
    +    os: [darwin]
    +
    +  '@img/sharp-darwin-x64@0.34.5':
    +    resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [x64]
    +    os: [darwin]
    +
    +  '@img/sharp-libvips-darwin-arm64@1.2.4':
    +    resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
    +    cpu: [arm64]
    +    os: [darwin]
    +
    +  '@img/sharp-libvips-darwin-x64@1.2.4':
    +    resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
    +    cpu: [x64]
    +    os: [darwin]
    +
    +  '@img/sharp-libvips-linux-arm64@1.2.4':
    +    resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
    +    cpu: [arm64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-libvips-linux-arm@1.2.4':
    +    resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
    +    cpu: [arm]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-libvips-linux-ppc64@1.2.4':
    +    resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
    +    cpu: [ppc64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-libvips-linux-riscv64@1.2.4':
    +    resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
    +    cpu: [riscv64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-libvips-linux-s390x@1.2.4':
    +    resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
    +    cpu: [s390x]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-libvips-linux-x64@1.2.4':
    +    resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
    +    cpu: [x64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
    +    resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
    +    cpu: [arm64]
    +    os: [linux]
    +    libc: [musl]
    +
    +  '@img/sharp-libvips-linuxmusl-x64@1.2.4':
    +    resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
    +    cpu: [x64]
    +    os: [linux]
    +    libc: [musl]
    +
    +  '@img/sharp-linux-arm64@0.34.5':
    +    resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [arm64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-linux-arm@0.34.5':
    +    resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [arm]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-linux-ppc64@0.34.5':
    +    resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [ppc64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-linux-riscv64@0.34.5':
    +    resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [riscv64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-linux-s390x@0.34.5':
    +    resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [s390x]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-linux-x64@0.34.5':
    +    resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [x64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@img/sharp-linuxmusl-arm64@0.34.5':
    +    resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [arm64]
    +    os: [linux]
    +    libc: [musl]
    +
    +  '@img/sharp-linuxmusl-x64@0.34.5':
    +    resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [x64]
    +    os: [linux]
    +    libc: [musl]
    +
    +  '@img/sharp-wasm32@0.34.5':
    +    resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [wasm32]
    +
    +  '@img/sharp-win32-arm64@0.34.5':
    +    resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [arm64]
    +    os: [win32]
    +
    +  '@img/sharp-win32-ia32@0.34.5':
    +    resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [ia32]
    +    os: [win32]
    +
    +  '@img/sharp-win32-x64@0.34.5':
    +    resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +    cpu: [x64]
    +    os: [win32]
    +
    +  '@jridgewell/resolve-uri@3.1.2':
    +    resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
    +    engines: {node: '>=6.0.0'}
    +
    +  '@jridgewell/sourcemap-codec@1.5.5':
    +    resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
    +
    +  '@jridgewell/trace-mapping@0.3.9':
    +    resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
    +
    +  '@noble/curves@1.9.7':
    +    resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
    +    engines: {node: ^14.21.3 || >=16}
    +
    +  '@noble/hashes@1.8.0':
    +    resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
    +    engines: {node: ^14.21.3 || >=16}
    +
    +  '@openzeppelin/relayer-plugin-channels@0.20.0':
    +    resolution: {integrity: sha512-buZ0Xcir1mPcevMBU3vzbq4v77YUsikWX+E1k31YxdE6p97GEn40RnxNzow/eLNVnS3WNwYZ1SuEZgsQE7KHAg==}
    +    engines: {node: '>=22.18.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm}
    +
    +  '@openzeppelin/relayer-sdk@1.10.0':
    +    resolution: {integrity: sha512-C7v4C63VnFR4keISicpcDf4Ix646sa1OdokzddFeSPEGlJIY2b/XJ9sIWiXOBVJVGzbB+RdX3/fbkygAPhcVNw==}
    +    engines: {node: '>=22.14.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm}
    +
    +  '@poppinss/colors@4.1.6':
    +    resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
    +
    +  '@poppinss/dumper@0.6.5':
    +    resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
    +
    +  '@poppinss/exception@1.2.3':
    +    resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
    +
    +  '@rollup/rollup-android-arm-eabi@4.62.2':
    +    resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
    +    cpu: [arm]
    +    os: [android]
    +
    +  '@rollup/rollup-android-arm64@4.62.2':
    +    resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==}
    +    cpu: [arm64]
    +    os: [android]
    +
    +  '@rollup/rollup-darwin-arm64@4.62.2':
    +    resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==}
    +    cpu: [arm64]
    +    os: [darwin]
    +
    +  '@rollup/rollup-darwin-x64@4.62.2':
    +    resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==}
    +    cpu: [x64]
    +    os: [darwin]
    +
    +  '@rollup/rollup-freebsd-arm64@4.62.2':
    +    resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==}
    +    cpu: [arm64]
    +    os: [freebsd]
    +
    +  '@rollup/rollup-freebsd-x64@4.62.2':
    +    resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==}
    +    cpu: [x64]
    +    os: [freebsd]
    +
    +  '@rollup/rollup-linux-arm-gnueabihf@4.62.2':
    +    resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==}
    +    cpu: [arm]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@rollup/rollup-linux-arm-musleabihf@4.62.2':
    +    resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==}
    +    cpu: [arm]
    +    os: [linux]
    +    libc: [musl]
    +
    +  '@rollup/rollup-linux-arm64-gnu@4.62.2':
    +    resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==}
    +    cpu: [arm64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@rollup/rollup-linux-arm64-musl@4.62.2':
    +    resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==}
    +    cpu: [arm64]
    +    os: [linux]
    +    libc: [musl]
    +
    +  '@rollup/rollup-linux-loong64-gnu@4.62.2':
    +    resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==}
    +    cpu: [loong64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@rollup/rollup-linux-loong64-musl@4.62.2':
    +    resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==}
    +    cpu: [loong64]
    +    os: [linux]
    +    libc: [musl]
    +
    +  '@rollup/rollup-linux-ppc64-gnu@4.62.2':
    +    resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==}
    +    cpu: [ppc64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@rollup/rollup-linux-ppc64-musl@4.62.2':
    +    resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==}
    +    cpu: [ppc64]
    +    os: [linux]
    +    libc: [musl]
    +
    +  '@rollup/rollup-linux-riscv64-gnu@4.62.2':
    +    resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==}
    +    cpu: [riscv64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@rollup/rollup-linux-riscv64-musl@4.62.2':
    +    resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==}
    +    cpu: [riscv64]
    +    os: [linux]
    +    libc: [musl]
    +
    +  '@rollup/rollup-linux-s390x-gnu@4.62.2':
    +    resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==}
    +    cpu: [s390x]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@rollup/rollup-linux-x64-gnu@4.62.2':
    +    resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==}
    +    cpu: [x64]
    +    os: [linux]
    +    libc: [glibc]
    +
    +  '@rollup/rollup-linux-x64-musl@4.62.2':
    +    resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==}
    +    cpu: [x64]
    +    os: [linux]
    +    libc: [musl]
    +
    +  '@rollup/rollup-openbsd-x64@4.62.2':
    +    resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}
    +    cpu: [x64]
    +    os: [openbsd]
    +
    +  '@rollup/rollup-openharmony-arm64@4.62.2':
    +    resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==}
    +    cpu: [arm64]
    +    os: [openharmony]
    +
    +  '@rollup/rollup-win32-arm64-msvc@4.62.2':
    +    resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==}
    +    cpu: [arm64]
    +    os: [win32]
    +
    +  '@rollup/rollup-win32-ia32-msvc@4.62.2':
    +    resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==}
    +    cpu: [ia32]
    +    os: [win32]
    +
    +  '@rollup/rollup-win32-x64-gnu@4.62.2':
    +    resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==}
    +    cpu: [x64]
    +    os: [win32]
    +
    +  '@rollup/rollup-win32-x64-msvc@4.62.2':
    +    resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==}
    +    cpu: [x64]
    +    os: [win32]
    +
    +  '@sindresorhus/is@7.2.0':
    +    resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
    +    engines: {node: '>=18'}
    +
    +  '@speed-highlight/core@1.2.17':
    +    resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==}
    +
    +  '@stellar/js-xdr@3.1.2':
    +    resolution: {integrity: sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==}
    +
    +  '@stellar/stellar-base@14.1.0':
    +    resolution: {integrity: sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==}
    +    engines: {node: '>=20.0.0'}
    +    deprecated: This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.
    +
    +  '@stellar/stellar-sdk@14.6.1':
    +    resolution: {integrity: sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==}
    +    engines: {node: '>=20.0.0'}
    +    hasBin: true
    +
    +  '@types/estree@1.0.9':
    +    resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
    +
    +  '@vitest/expect@2.1.9':
    +    resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
    +
    +  '@vitest/mocker@2.1.9':
    +    resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
    +    peerDependencies:
    +      msw: ^2.4.9
    +      vite: ^5.0.0
    +    peerDependenciesMeta:
    +      msw:
    +        optional: true
    +      vite:
    +        optional: true
    +
    +  '@vitest/pretty-format@2.1.9':
    +    resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
    +
    +  '@vitest/runner@2.1.9':
    +    resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
    +
    +  '@vitest/snapshot@2.1.9':
    +    resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
    +
    +  '@vitest/spy@2.1.9':
    +    resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
    +
    +  '@vitest/utils@2.1.9':
    +    resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
    +
    +  agent-base@6.0.2:
    +    resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
    +    engines: {node: '>= 6.0.0'}
    +
    +  assertion-error@2.0.1:
    +    resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
    +    engines: {node: '>=12'}
    +
    +  asynckit@0.4.0:
    +    resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
    +
    +  available-typed-arrays@1.0.7:
    +    resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
    +    engines: {node: '>= 0.4'}
    +
    +  axios@1.18.1:
    +    resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==}
    +
    +  base32.js@0.1.0:
    +    resolution: {integrity: sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==}
    +    engines: {node: '>=0.12.0'}
    +
    +  base64-js@1.5.1:
    +    resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
    +
    +  bignumber.js@9.3.1:
    +    resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
    +
    +  blake3-wasm@2.1.5:
    +    resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
    +
    +  buffer@6.0.3:
    +    resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
    +
    +  cac@6.7.14:
    +    resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
    +    engines: {node: '>=8'}
    +
    +  call-bind-apply-helpers@1.0.2:
    +    resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
    +    engines: {node: '>= 0.4'}
    +
    +  call-bind@1.0.9:
    +    resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
    +    engines: {node: '>= 0.4'}
    +
    +  call-bound@1.0.4:
    +    resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
    +    engines: {node: '>= 0.4'}
    +
    +  chai@5.3.3:
    +    resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
    +    engines: {node: '>=18'}
    +
    +  check-error@2.1.3:
    +    resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
    +    engines: {node: '>= 16'}
    +
    +  combined-stream@1.0.8:
    +    resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
    +    engines: {node: '>= 0.8'}
    +
    +  commander@14.0.3:
    +    resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
    +    engines: {node: '>=20'}
    +
    +  cookie@1.1.1:
    +    resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
    +    engines: {node: '>=18'}
    +
    +  debug@4.4.3:
    +    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
    +    engines: {node: '>=6.0'}
    +    peerDependencies:
    +      supports-color: '*'
    +    peerDependenciesMeta:
    +      supports-color:
    +        optional: true
    +
    +  deep-eql@5.0.2:
    +    resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
    +    engines: {node: '>=6'}
    +
    +  define-data-property@1.1.4:
    +    resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
    +    engines: {node: '>= 0.4'}
    +
    +  delayed-stream@1.0.0:
    +    resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
    +    engines: {node: '>=0.4.0'}
    +
    +  detect-libc@2.1.2:
    +    resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
    +    engines: {node: '>=8'}
    +
    +  dunder-proto@1.0.1:
    +    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
    +    engines: {node: '>= 0.4'}
    +
    +  error-stack-parser-es@1.0.5:
    +    resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
    +
    +  es-define-property@1.0.1:
    +    resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
    +    engines: {node: '>= 0.4'}
    +
    +  es-errors@1.3.0:
    +    resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
    +    engines: {node: '>= 0.4'}
    +
    +  es-module-lexer@1.7.0:
    +    resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
    +
    +  es-object-atoms@1.1.2:
    +    resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
    +    engines: {node: '>= 0.4'}
    +
    +  es-set-tostringtag@2.1.0:
    +    resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
    +    engines: {node: '>= 0.4'}
    +
    +  esbuild@0.21.5:
    +    resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
    +    engines: {node: '>=12'}
    +    hasBin: true
    +
    +  esbuild@0.28.1:
    +    resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
    +    engines: {node: '>=18'}
    +    hasBin: true
    +
    +  estree-walker@3.0.3:
    +    resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
    +
    +  eventsource@2.0.2:
    +    resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==}
    +    engines: {node: '>=12.0.0'}
    +
    +  expect-type@1.4.0:
    +    resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
    +    engines: {node: '>=12.0.0'}
    +
    +  feaxios@0.0.23:
    +    resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==}
    +
    +  follow-redirects@1.16.0:
    +    resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
    +    engines: {node: '>=4.0'}
    +    peerDependencies:
    +      debug: '*'
    +    peerDependenciesMeta:
    +      debug:
    +        optional: true
    +
    +  for-each@0.3.5:
    +    resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
    +    engines: {node: '>= 0.4'}
    +
    +  form-data@4.0.6:
    +    resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
    +    engines: {node: '>= 6'}
    +
    +  fsevents@2.3.3:
    +    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
    +    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
    +    os: [darwin]
    +
    +  function-bind@1.1.2:
    +    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
    +
    +  get-intrinsic@1.3.0:
    +    resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
    +    engines: {node: '>= 0.4'}
    +
    +  get-proto@1.0.1:
    +    resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
    +    engines: {node: '>= 0.4'}
    +
    +  gopd@1.2.0:
    +    resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
    +    engines: {node: '>= 0.4'}
    +
    +  has-property-descriptors@1.0.2:
    +    resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
    +
    +  has-symbols@1.1.0:
    +    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
    +    engines: {node: '>= 0.4'}
    +
    +  has-tostringtag@1.0.2:
    +    resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
    +    engines: {node: '>= 0.4'}
    +
    +  hasown@2.0.4:
    +    resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
    +    engines: {node: '>= 0.4'}
    +
    +  hono@4.12.29:
    +    resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==}
    +    engines: {node: '>=16.9.0'}
    +
    +  https-proxy-agent@5.0.1:
    +    resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
    +    engines: {node: '>= 6'}
    +
    +  ieee754@1.2.1:
    +    resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
    +
    +  inherits@2.0.4:
    +    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
    +
    +  is-callable@1.2.7:
    +    resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
    +    engines: {node: '>= 0.4'}
    +
    +  is-retry-allowed@3.0.0:
    +    resolution: {integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==}
    +    engines: {node: '>=12'}
    +
    +  is-typed-array@1.1.15:
    +    resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
    +    engines: {node: '>= 0.4'}
    +
    +  isarray@2.0.5:
    +    resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
    +
    +  kleur@4.1.5:
    +    resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
    +    engines: {node: '>=6'}
    +
    +  loupe@3.2.1:
    +    resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
    +
    +  magic-string@0.30.21:
    +    resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
    +
    +  math-intrinsics@1.1.0:
    +    resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
    +    engines: {node: '>= 0.4'}
    +
    +  mime-db@1.52.0:
    +    resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
    +    engines: {node: '>= 0.6'}
    +
    +  mime-types@2.1.35:
    +    resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
    +    engines: {node: '>= 0.6'}
    +
    +  miniflare@4.20260708.1:
    +    resolution: {integrity: sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA==}
    +    engines: {node: '>=22.0.0'}
    +    hasBin: true
    +
    +  ms@2.1.3:
    +    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
    +
    +  nanoid@3.3.15:
    +    resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
    +    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
    +    hasBin: true
    +
    +  path-to-regexp@6.3.0:
    +    resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
    +
    +  pathe@1.1.2:
    +    resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
    +
    +  pathe@2.0.3:
    +    resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
    +
    +  pathval@2.0.1:
    +    resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
    +    engines: {node: '>= 14.16'}
    +
    +  picocolors@1.1.1:
    +    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
    +
    +  possible-typed-array-names@1.1.0:
    +    resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
    +    engines: {node: '>= 0.4'}
    +
    +  postcss@8.5.17:
    +    resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==}
    +    engines: {node: ^10 || ^12 || >=14}
    +
    +  proxy-from-env@2.1.0:
    +    resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
    +    engines: {node: '>=10'}
    +
    +  randombytes@2.1.0:
    +    resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
    +
    +  rollup@4.62.2:
    +    resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==}
    +    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
    +    hasBin: true
    +
    +  safe-buffer@5.2.1:
    +    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
    +
    +  semver@7.8.5:
    +    resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
    +    engines: {node: '>=10'}
    +    hasBin: true
    +
    +  set-function-length@1.2.2:
    +    resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
    +    engines: {node: '>= 0.4'}
    +
    +  sha.js@2.4.12:
    +    resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
    +    engines: {node: '>= 0.10'}
    +    hasBin: true
    +
    +  sharp@0.34.5:
    +    resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
    +    engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
    +
    +  siginfo@2.0.0:
    +    resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
    +
    +  source-map-js@1.2.1:
    +    resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
    +    engines: {node: '>=0.10.0'}
    +
    +  stackback@0.0.2:
    +    resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
    +
    +  std-env@3.10.0:
    +    resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
    +
    +  supports-color@10.2.2:
    +    resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
    +    engines: {node: '>=18'}
    +
    +  tinybench@2.9.0:
    +    resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
    +
    +  tinyexec@0.3.2:
    +    resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
    +
    +  tinypool@1.1.1:
    +    resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
    +    engines: {node: ^18.0.0 || >=20.0.0}
    +
    +  tinyrainbow@1.2.0:
    +    resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
    +    engines: {node: '>=14.0.0'}
    +
    +  tinyspy@3.0.2:
    +    resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
    +    engines: {node: '>=14.0.0'}
    +
    +  to-buffer@1.2.2:
    +    resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
    +    engines: {node: '>= 0.4'}
    +
    +  toml@3.0.0:
    +    resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}
    +
    +  tslib@2.8.1:
    +    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
    +
    +  typed-array-buffer@1.0.3:
    +    resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
    +    engines: {node: '>= 0.4'}
    +
    +  typescript@5.9.3:
    +    resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
    +    engines: {node: '>=14.17'}
    +    hasBin: true
    +
    +  undici@7.28.0:
    +    resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
    +    engines: {node: '>=20.18.1'}
    +
    +  unenv@2.0.0-rc.24:
    +    resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
    +
    +  urijs@1.19.11:
    +    resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==}
    +
    +  vite-node@2.1.9:
    +    resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
    +    engines: {node: ^18.0.0 || >=20.0.0}
    +    hasBin: true
    +
    +  vite@5.4.21:
    +    resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
    +    engines: {node: ^18.0.0 || >=20.0.0}
    +    hasBin: true
    +    peerDependencies:
    +      '@types/node': ^18.0.0 || >=20.0.0
    +      less: '*'
    +      lightningcss: ^1.21.0
    +      sass: '*'
    +      sass-embedded: '*'
    +      stylus: '*'
    +      sugarss: '*'
    +      terser: ^5.4.0
    +    peerDependenciesMeta:
    +      '@types/node':
    +        optional: true
    +      less:
    +        optional: true
    +      lightningcss:
    +        optional: true
    +      sass:
    +        optional: true
    +      sass-embedded:
    +        optional: true
    +      stylus:
    +        optional: true
    +      sugarss:
    +        optional: true
    +      terser:
    +        optional: true
    +
    +  vitest@2.1.9:
    +    resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
    +    engines: {node: ^18.0.0 || >=20.0.0}
    +    hasBin: true
    +    peerDependencies:
    +      '@edge-runtime/vm': '*'
    +      '@types/node': ^18.0.0 || >=20.0.0
    +      '@vitest/browser': 2.1.9
    +      '@vitest/ui': 2.1.9
    +      happy-dom: '*'
    +      jsdom: '*'
    +    peerDependenciesMeta:
    +      '@edge-runtime/vm':
    +        optional: true
    +      '@types/node':
    +        optional: true
    +      '@vitest/browser':
    +        optional: true
    +      '@vitest/ui':
    +        optional: true
    +      happy-dom:
    +        optional: true
    +      jsdom:
    +        optional: true
    +
    +  which-typed-array@1.1.22:
    +    resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
    +    engines: {node: '>= 0.4'}
    +
    +  why-is-node-running@2.3.0:
    +    resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
    +    engines: {node: '>=8'}
    +    hasBin: true
    +
    +  workerd@1.20260708.1:
    +    resolution: {integrity: sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w==}
    +    engines: {node: '>=16'}
    +    hasBin: true
    +
    +  wrangler@4.110.0:
    +    resolution: {integrity: sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==}
    +    engines: {node: '>=22.0.0'}
    +    hasBin: true
    +    peerDependencies:
    +      '@cloudflare/workers-types': ^5.20260708.1
    +    peerDependenciesMeta:
    +      '@cloudflare/workers-types':
    +        optional: true
    +
    +  ws@8.21.0:
    +    resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
    +    engines: {node: '>=10.0.0'}
    +    peerDependencies:
    +      bufferutil: ^4.0.1
    +      utf-8-validate: '>=5.0.2'
    +    peerDependenciesMeta:
    +      bufferutil:
    +        optional: true
    +      utf-8-validate:
    +        optional: true
    +
    +  youch-core@0.3.3:
    +    resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
    +
    +  youch@4.1.0-beta.10:
    +    resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
    +
    +snapshots:
    +
    +  '@actions/exec@1.1.1':
    +    dependencies:
    +      '@actions/io': 1.1.3
    +
    +  '@actions/io@1.1.3': {}
    +
    +  '@cloudflare/kv-asset-handler@0.5.0': {}
    +
    +  '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1)':
    +    dependencies:
    +      unenv: 2.0.0-rc.24
    +    optionalDependencies:
    +      workerd: 1.20260708.1
    +
    +  '@cloudflare/workerd-darwin-64@1.20260708.1':
    +    optional: true
    +
    +  '@cloudflare/workerd-darwin-arm64@1.20260708.1':
    +    optional: true
    +
    +  '@cloudflare/workerd-linux-64@1.20260708.1':
    +    optional: true
    +
    +  '@cloudflare/workerd-linux-arm64@1.20260708.1':
    +    optional: true
    +
    +  '@cloudflare/workerd-windows-64@1.20260708.1':
    +    optional: true
    +
    +  '@cloudflare/workers-types@4.20260702.1': {}
    +
    +  '@cspotcode/source-map-support@0.8.1':
    +    dependencies:
    +      '@jridgewell/trace-mapping': 0.3.9
    +
    +  '@emnapi/runtime@1.11.2':
    +    dependencies:
    +      tslib: 2.8.1
    +    optional: true
    +
    +  '@esbuild/aix-ppc64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/aix-ppc64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/android-arm64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/android-arm64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/android-arm@0.21.5':
    +    optional: true
    +
    +  '@esbuild/android-arm@0.28.1':
    +    optional: true
    +
    +  '@esbuild/android-x64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/android-x64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/darwin-arm64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/darwin-arm64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/darwin-x64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/darwin-x64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/freebsd-arm64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/freebsd-arm64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/freebsd-x64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/freebsd-x64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/linux-arm64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/linux-arm64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/linux-arm@0.21.5':
    +    optional: true
    +
    +  '@esbuild/linux-arm@0.28.1':
    +    optional: true
    +
    +  '@esbuild/linux-ia32@0.21.5':
    +    optional: true
    +
    +  '@esbuild/linux-ia32@0.28.1':
    +    optional: true
    +
    +  '@esbuild/linux-loong64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/linux-loong64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/linux-mips64el@0.21.5':
    +    optional: true
    +
    +  '@esbuild/linux-mips64el@0.28.1':
    +    optional: true
    +
    +  '@esbuild/linux-ppc64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/linux-ppc64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/linux-riscv64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/linux-riscv64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/linux-s390x@0.21.5':
    +    optional: true
    +
    +  '@esbuild/linux-s390x@0.28.1':
    +    optional: true
    +
    +  '@esbuild/linux-x64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/linux-x64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/netbsd-arm64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/netbsd-x64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/netbsd-x64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/openbsd-arm64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/openbsd-x64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/openbsd-x64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/openharmony-arm64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/sunos-x64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/sunos-x64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/win32-arm64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/win32-arm64@0.28.1':
    +    optional: true
    +
    +  '@esbuild/win32-ia32@0.21.5':
    +    optional: true
    +
    +  '@esbuild/win32-ia32@0.28.1':
    +    optional: true
    +
    +  '@esbuild/win32-x64@0.21.5':
    +    optional: true
    +
    +  '@esbuild/win32-x64@0.28.1':
    +    optional: true
    +
    +  '@img/colour@1.1.0': {}
    +
    +  '@img/sharp-darwin-arm64@0.34.5':
    +    optionalDependencies:
    +      '@img/sharp-libvips-darwin-arm64': 1.2.4
    +    optional: true
    +
    +  '@img/sharp-darwin-x64@0.34.5':
    +    optionalDependencies:
    +      '@img/sharp-libvips-darwin-x64': 1.2.4
    +    optional: true
    +
    +  '@img/sharp-libvips-darwin-arm64@1.2.4':
    +    optional: true
    +
    +  '@img/sharp-libvips-darwin-x64@1.2.4':
    +    optional: true
    +
    +  '@img/sharp-libvips-linux-arm64@1.2.4':
    +    optional: true
    +
    +  '@img/sharp-libvips-linux-arm@1.2.4':
    +    optional: true
    +
    +  '@img/sharp-libvips-linux-ppc64@1.2.4':
    +    optional: true
    +
    +  '@img/sharp-libvips-linux-riscv64@1.2.4':
    +    optional: true
    +
    +  '@img/sharp-libvips-linux-s390x@1.2.4':
    +    optional: true
    +
    +  '@img/sharp-libvips-linux-x64@1.2.4':
    +    optional: true
    +
    +  '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
    +    optional: true
    +
    +  '@img/sharp-libvips-linuxmusl-x64@1.2.4':
    +    optional: true
    +
    +  '@img/sharp-linux-arm64@0.34.5':
    +    optionalDependencies:
    +      '@img/sharp-libvips-linux-arm64': 1.2.4
    +    optional: true
    +
    +  '@img/sharp-linux-arm@0.34.5':
    +    optionalDependencies:
    +      '@img/sharp-libvips-linux-arm': 1.2.4
    +    optional: true
    +
    +  '@img/sharp-linux-ppc64@0.34.5':
    +    optionalDependencies:
    +      '@img/sharp-libvips-linux-ppc64': 1.2.4
    +    optional: true
    +
    +  '@img/sharp-linux-riscv64@0.34.5':
    +    optionalDependencies:
    +      '@img/sharp-libvips-linux-riscv64': 1.2.4
    +    optional: true
    +
    +  '@img/sharp-linux-s390x@0.34.5':
    +    optionalDependencies:
    +      '@img/sharp-libvips-linux-s390x': 1.2.4
    +    optional: true
    +
    +  '@img/sharp-linux-x64@0.34.5':
    +    optionalDependencies:
    +      '@img/sharp-libvips-linux-x64': 1.2.4
    +    optional: true
    +
    +  '@img/sharp-linuxmusl-arm64@0.34.5':
    +    optionalDependencies:
    +      '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
    +    optional: true
    +
    +  '@img/sharp-linuxmusl-x64@0.34.5':
    +    optionalDependencies:
    +      '@img/sharp-libvips-linuxmusl-x64': 1.2.4
    +    optional: true
    +
    +  '@img/sharp-wasm32@0.34.5':
    +    dependencies:
    +      '@emnapi/runtime': 1.11.2
    +    optional: true
    +
    +  '@img/sharp-win32-arm64@0.34.5':
    +    optional: true
    +
    +  '@img/sharp-win32-ia32@0.34.5':
    +    optional: true
    +
    +  '@img/sharp-win32-x64@0.34.5':
    +    optional: true
    +
    +  '@jridgewell/resolve-uri@3.1.2': {}
    +
    +  '@jridgewell/sourcemap-codec@1.5.5': {}
    +
    +  '@jridgewell/trace-mapping@0.3.9':
    +    dependencies:
    +      '@jridgewell/resolve-uri': 3.1.2
    +      '@jridgewell/sourcemap-codec': 1.5.5
    +
    +  '@noble/curves@1.9.7':
    +    dependencies:
    +      '@noble/hashes': 1.8.0
    +
    +  '@noble/hashes@1.8.0': {}
    +
    +  '@openzeppelin/relayer-plugin-channels@0.20.0':
    +    dependencies:
    +      '@actions/exec': 1.1.1
    +      '@openzeppelin/relayer-sdk': 1.10.0
    +      '@stellar/stellar-sdk': 14.6.1
    +      axios: 1.18.1
    +    transitivePeerDependencies:
    +      - debug
    +      - supports-color
    +
    +  '@openzeppelin/relayer-sdk@1.10.0':
    +    dependencies:
    +      axios: 1.18.1
    +    transitivePeerDependencies:
    +      - debug
    +      - supports-color
    +
    +  '@poppinss/colors@4.1.6':
    +    dependencies:
    +      kleur: 4.1.5
    +
    +  '@poppinss/dumper@0.6.5':
    +    dependencies:
    +      '@poppinss/colors': 4.1.6
    +      '@sindresorhus/is': 7.2.0
    +      supports-color: 10.2.2
    +
    +  '@poppinss/exception@1.2.3': {}
    +
    +  '@rollup/rollup-android-arm-eabi@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-android-arm64@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-darwin-arm64@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-darwin-x64@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-freebsd-arm64@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-freebsd-x64@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-arm-gnueabihf@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-arm-musleabihf@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-arm64-gnu@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-arm64-musl@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-loong64-gnu@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-loong64-musl@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-ppc64-gnu@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-ppc64-musl@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-riscv64-gnu@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-riscv64-musl@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-s390x-gnu@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-x64-gnu@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-linux-x64-musl@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-openbsd-x64@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-openharmony-arm64@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-win32-arm64-msvc@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-win32-ia32-msvc@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-win32-x64-gnu@4.62.2':
    +    optional: true
    +
    +  '@rollup/rollup-win32-x64-msvc@4.62.2':
    +    optional: true
    +
    +  '@sindresorhus/is@7.2.0': {}
    +
    +  '@speed-highlight/core@1.2.17': {}
    +
    +  '@stellar/js-xdr@3.1.2': {}
    +
    +  '@stellar/stellar-base@14.1.0':
    +    dependencies:
    +      '@noble/curves': 1.9.7
    +      '@stellar/js-xdr': 3.1.2
    +      base32.js: 0.1.0
    +      bignumber.js: 9.3.1
    +      buffer: 6.0.3
    +      sha.js: 2.4.12
    +
    +  '@stellar/stellar-sdk@14.6.1':
    +    dependencies:
    +      '@stellar/stellar-base': 14.1.0
    +      axios: 1.18.1
    +      bignumber.js: 9.3.1
    +      commander: 14.0.3
    +      eventsource: 2.0.2
    +      feaxios: 0.0.23
    +      randombytes: 2.1.0
    +      toml: 3.0.0
    +      urijs: 1.19.11
    +    transitivePeerDependencies:
    +      - debug
    +      - supports-color
    +
    +  '@types/estree@1.0.9': {}
    +
    +  '@vitest/expect@2.1.9':
    +    dependencies:
    +      '@vitest/spy': 2.1.9
    +      '@vitest/utils': 2.1.9
    +      chai: 5.3.3
    +      tinyrainbow: 1.2.0
    +
    +  '@vitest/mocker@2.1.9(vite@5.4.21)':
    +    dependencies:
    +      '@vitest/spy': 2.1.9
    +      estree-walker: 3.0.3
    +      magic-string: 0.30.21
    +    optionalDependencies:
    +      vite: 5.4.21
    +
    +  '@vitest/pretty-format@2.1.9':
    +    dependencies:
    +      tinyrainbow: 1.2.0
    +
    +  '@vitest/runner@2.1.9':
    +    dependencies:
    +      '@vitest/utils': 2.1.9
    +      pathe: 1.1.2
    +
    +  '@vitest/snapshot@2.1.9':
    +    dependencies:
    +      '@vitest/pretty-format': 2.1.9
    +      magic-string: 0.30.21
    +      pathe: 1.1.2
    +
    +  '@vitest/spy@2.1.9':
    +    dependencies:
    +      tinyspy: 3.0.2
    +
    +  '@vitest/utils@2.1.9':
    +    dependencies:
    +      '@vitest/pretty-format': 2.1.9
    +      loupe: 3.2.1
    +      tinyrainbow: 1.2.0
    +
    +  agent-base@6.0.2:
    +    dependencies:
    +      debug: 4.4.3
    +    transitivePeerDependencies:
    +      - supports-color
    +
    +  assertion-error@2.0.1: {}
    +
    +  asynckit@0.4.0: {}
    +
    +  available-typed-arrays@1.0.7:
    +    dependencies:
    +      possible-typed-array-names: 1.1.0
    +
    +  axios@1.18.1:
    +    dependencies:
    +      follow-redirects: 1.16.0
    +      form-data: 4.0.6
    +      https-proxy-agent: 5.0.1
    +      proxy-from-env: 2.1.0
    +    transitivePeerDependencies:
    +      - debug
    +      - supports-color
    +
    +  base32.js@0.1.0: {}
    +
    +  base64-js@1.5.1: {}
    +
    +  bignumber.js@9.3.1: {}
    +
    +  blake3-wasm@2.1.5: {}
    +
    +  buffer@6.0.3:
    +    dependencies:
    +      base64-js: 1.5.1
    +      ieee754: 1.2.1
    +
    +  cac@6.7.14: {}
    +
    +  call-bind-apply-helpers@1.0.2:
    +    dependencies:
    +      es-errors: 1.3.0
    +      function-bind: 1.1.2
    +
    +  call-bind@1.0.9:
    +    dependencies:
    +      call-bind-apply-helpers: 1.0.2
    +      es-define-property: 1.0.1
    +      get-intrinsic: 1.3.0
    +      set-function-length: 1.2.2
    +
    +  call-bound@1.0.4:
    +    dependencies:
    +      call-bind-apply-helpers: 1.0.2
    +      get-intrinsic: 1.3.0
    +
    +  chai@5.3.3:
    +    dependencies:
    +      assertion-error: 2.0.1
    +      check-error: 2.1.3
    +      deep-eql: 5.0.2
    +      loupe: 3.2.1
    +      pathval: 2.0.1
    +
    +  check-error@2.1.3: {}
    +
    +  combined-stream@1.0.8:
    +    dependencies:
    +      delayed-stream: 1.0.0
    +
    +  commander@14.0.3: {}
    +
    +  cookie@1.1.1: {}
    +
    +  debug@4.4.3:
    +    dependencies:
    +      ms: 2.1.3
    +
    +  deep-eql@5.0.2: {}
    +
    +  define-data-property@1.1.4:
    +    dependencies:
    +      es-define-property: 1.0.1
    +      es-errors: 1.3.0
    +      gopd: 1.2.0
    +
    +  delayed-stream@1.0.0: {}
    +
    +  detect-libc@2.1.2: {}
    +
    +  dunder-proto@1.0.1:
    +    dependencies:
    +      call-bind-apply-helpers: 1.0.2
    +      es-errors: 1.3.0
    +      gopd: 1.2.0
    +
    +  error-stack-parser-es@1.0.5: {}
    +
    +  es-define-property@1.0.1: {}
    +
    +  es-errors@1.3.0: {}
    +
    +  es-module-lexer@1.7.0: {}
    +
    +  es-object-atoms@1.1.2:
    +    dependencies:
    +      es-errors: 1.3.0
    +
    +  es-set-tostringtag@2.1.0:
    +    dependencies:
    +      es-errors: 1.3.0
    +      get-intrinsic: 1.3.0
    +      has-tostringtag: 1.0.2
    +      hasown: 2.0.4
    +
    +  esbuild@0.21.5:
    +    optionalDependencies:
    +      '@esbuild/aix-ppc64': 0.21.5
    +      '@esbuild/android-arm': 0.21.5
    +      '@esbuild/android-arm64': 0.21.5
    +      '@esbuild/android-x64': 0.21.5
    +      '@esbuild/darwin-arm64': 0.21.5
    +      '@esbuild/darwin-x64': 0.21.5
    +      '@esbuild/freebsd-arm64': 0.21.5
    +      '@esbuild/freebsd-x64': 0.21.5
    +      '@esbuild/linux-arm': 0.21.5
    +      '@esbuild/linux-arm64': 0.21.5
    +      '@esbuild/linux-ia32': 0.21.5
    +      '@esbuild/linux-loong64': 0.21.5
    +      '@esbuild/linux-mips64el': 0.21.5
    +      '@esbuild/linux-ppc64': 0.21.5
    +      '@esbuild/linux-riscv64': 0.21.5
    +      '@esbuild/linux-s390x': 0.21.5
    +      '@esbuild/linux-x64': 0.21.5
    +      '@esbuild/netbsd-x64': 0.21.5
    +      '@esbuild/openbsd-x64': 0.21.5
    +      '@esbuild/sunos-x64': 0.21.5
    +      '@esbuild/win32-arm64': 0.21.5
    +      '@esbuild/win32-ia32': 0.21.5
    +      '@esbuild/win32-x64': 0.21.5
    +
    +  esbuild@0.28.1:
    +    optionalDependencies:
    +      '@esbuild/aix-ppc64': 0.28.1
    +      '@esbuild/android-arm': 0.28.1
    +      '@esbuild/android-arm64': 0.28.1
    +      '@esbuild/android-x64': 0.28.1
    +      '@esbuild/darwin-arm64': 0.28.1
    +      '@esbuild/darwin-x64': 0.28.1
    +      '@esbuild/freebsd-arm64': 0.28.1
    +      '@esbuild/freebsd-x64': 0.28.1
    +      '@esbuild/linux-arm': 0.28.1
    +      '@esbuild/linux-arm64': 0.28.1
    +      '@esbuild/linux-ia32': 0.28.1
    +      '@esbuild/linux-loong64': 0.28.1
    +      '@esbuild/linux-mips64el': 0.28.1
    +      '@esbuild/linux-ppc64': 0.28.1
    +      '@esbuild/linux-riscv64': 0.28.1
    +      '@esbuild/linux-s390x': 0.28.1
    +      '@esbuild/linux-x64': 0.28.1
    +      '@esbuild/netbsd-arm64': 0.28.1
    +      '@esbuild/netbsd-x64': 0.28.1
    +      '@esbuild/openbsd-arm64': 0.28.1
    +      '@esbuild/openbsd-x64': 0.28.1
    +      '@esbuild/openharmony-arm64': 0.28.1
    +      '@esbuild/sunos-x64': 0.28.1
    +      '@esbuild/win32-arm64': 0.28.1
    +      '@esbuild/win32-ia32': 0.28.1
    +      '@esbuild/win32-x64': 0.28.1
    +
    +  estree-walker@3.0.3:
    +    dependencies:
    +      '@types/estree': 1.0.9
    +
    +  eventsource@2.0.2: {}
    +
    +  expect-type@1.4.0: {}
    +
    +  feaxios@0.0.23:
    +    dependencies:
    +      is-retry-allowed: 3.0.0
    +
    +  follow-redirects@1.16.0: {}
    +
    +  for-each@0.3.5:
    +    dependencies:
    +      is-callable: 1.2.7
    +
    +  form-data@4.0.6:
    +    dependencies:
    +      asynckit: 0.4.0
    +      combined-stream: 1.0.8
    +      es-set-tostringtag: 2.1.0
    +      hasown: 2.0.4
    +      mime-types: 2.1.35
    +
    +  fsevents@2.3.3:
    +    optional: true
    +
    +  function-bind@1.1.2: {}
    +
    +  get-intrinsic@1.3.0:
    +    dependencies:
    +      call-bind-apply-helpers: 1.0.2
    +      es-define-property: 1.0.1
    +      es-errors: 1.3.0
    +      es-object-atoms: 1.1.2
    +      function-bind: 1.1.2
    +      get-proto: 1.0.1
    +      gopd: 1.2.0
    +      has-symbols: 1.1.0
    +      hasown: 2.0.4
    +      math-intrinsics: 1.1.0
    +
    +  get-proto@1.0.1:
    +    dependencies:
    +      dunder-proto: 1.0.1
    +      es-object-atoms: 1.1.2
    +
    +  gopd@1.2.0: {}
    +
    +  has-property-descriptors@1.0.2:
    +    dependencies:
    +      es-define-property: 1.0.1
    +
    +  has-symbols@1.1.0: {}
    +
    +  has-tostringtag@1.0.2:
    +    dependencies:
    +      has-symbols: 1.1.0
    +
    +  hasown@2.0.4:
    +    dependencies:
    +      function-bind: 1.1.2
    +
    +  hono@4.12.29: {}
    +
    +  https-proxy-agent@5.0.1:
    +    dependencies:
    +      agent-base: 6.0.2
    +      debug: 4.4.3
    +    transitivePeerDependencies:
    +      - supports-color
    +
    +  ieee754@1.2.1: {}
    +
    +  inherits@2.0.4: {}
    +
    +  is-callable@1.2.7: {}
    +
    +  is-retry-allowed@3.0.0: {}
    +
    +  is-typed-array@1.1.15:
    +    dependencies:
    +      which-typed-array: 1.1.22
    +
    +  isarray@2.0.5: {}
    +
    +  kleur@4.1.5: {}
    +
    +  loupe@3.2.1: {}
    +
    +  magic-string@0.30.21:
    +    dependencies:
    +      '@jridgewell/sourcemap-codec': 1.5.5
    +
    +  math-intrinsics@1.1.0: {}
    +
    +  mime-db@1.52.0: {}
    +
    +  mime-types@2.1.35:
    +    dependencies:
    +      mime-db: 1.52.0
    +
    +  miniflare@4.20260708.1:
    +    dependencies:
    +      '@cspotcode/source-map-support': 0.8.1
    +      sharp: 0.34.5
    +      undici: 7.28.0
    +      workerd: 1.20260708.1
    +      ws: 8.21.0
    +      youch: 4.1.0-beta.10
    +    transitivePeerDependencies:
    +      - bufferutil
    +      - utf-8-validate
    +
    +  ms@2.1.3: {}
    +
    +  nanoid@3.3.15: {}
    +
    +  path-to-regexp@6.3.0: {}
    +
    +  pathe@1.1.2: {}
    +
    +  pathe@2.0.3: {}
    +
    +  pathval@2.0.1: {}
    +
    +  picocolors@1.1.1: {}
    +
    +  possible-typed-array-names@1.1.0: {}
    +
    +  postcss@8.5.17:
    +    dependencies:
    +      nanoid: 3.3.15
    +      picocolors: 1.1.1
    +      source-map-js: 1.2.1
    +
    +  proxy-from-env@2.1.0: {}
    +
    +  randombytes@2.1.0:
    +    dependencies:
    +      safe-buffer: 5.2.1
    +
    +  rollup@4.62.2:
    +    dependencies:
    +      '@types/estree': 1.0.9
    +    optionalDependencies:
    +      '@rollup/rollup-android-arm-eabi': 4.62.2
    +      '@rollup/rollup-android-arm64': 4.62.2
    +      '@rollup/rollup-darwin-arm64': 4.62.2
    +      '@rollup/rollup-darwin-x64': 4.62.2
    +      '@rollup/rollup-freebsd-arm64': 4.62.2
    +      '@rollup/rollup-freebsd-x64': 4.62.2
    +      '@rollup/rollup-linux-arm-gnueabihf': 4.62.2
    +      '@rollup/rollup-linux-arm-musleabihf': 4.62.2
    +      '@rollup/rollup-linux-arm64-gnu': 4.62.2
    +      '@rollup/rollup-linux-arm64-musl': 4.62.2
    +      '@rollup/rollup-linux-loong64-gnu': 4.62.2
    +      '@rollup/rollup-linux-loong64-musl': 4.62.2
    +      '@rollup/rollup-linux-ppc64-gnu': 4.62.2
    +      '@rollup/rollup-linux-ppc64-musl': 4.62.2
    +      '@rollup/rollup-linux-riscv64-gnu': 4.62.2
    +      '@rollup/rollup-linux-riscv64-musl': 4.62.2
    +      '@rollup/rollup-linux-s390x-gnu': 4.62.2
    +      '@rollup/rollup-linux-x64-gnu': 4.62.2
    +      '@rollup/rollup-linux-x64-musl': 4.62.2
    +      '@rollup/rollup-openbsd-x64': 4.62.2
    +      '@rollup/rollup-openharmony-arm64': 4.62.2
    +      '@rollup/rollup-win32-arm64-msvc': 4.62.2
    +      '@rollup/rollup-win32-ia32-msvc': 4.62.2
    +      '@rollup/rollup-win32-x64-gnu': 4.62.2
    +      '@rollup/rollup-win32-x64-msvc': 4.62.2
    +      fsevents: 2.3.3
    +
    +  safe-buffer@5.2.1: {}
    +
    +  semver@7.8.5: {}
    +
    +  set-function-length@1.2.2:
    +    dependencies:
    +      define-data-property: 1.1.4
    +      es-errors: 1.3.0
    +      function-bind: 1.1.2
    +      get-intrinsic: 1.3.0
    +      gopd: 1.2.0
    +      has-property-descriptors: 1.0.2
    +
    +  sha.js@2.4.12:
    +    dependencies:
    +      inherits: 2.0.4
    +      safe-buffer: 5.2.1
    +      to-buffer: 1.2.2
    +
    +  sharp@0.34.5:
    +    dependencies:
    +      '@img/colour': 1.1.0
    +      detect-libc: 2.1.2
    +      semver: 7.8.5
    +    optionalDependencies:
    +      '@img/sharp-darwin-arm64': 0.34.5
    +      '@img/sharp-darwin-x64': 0.34.5
    +      '@img/sharp-libvips-darwin-arm64': 1.2.4
    +      '@img/sharp-libvips-darwin-x64': 1.2.4
    +      '@img/sharp-libvips-linux-arm': 1.2.4
    +      '@img/sharp-libvips-linux-arm64': 1.2.4
    +      '@img/sharp-libvips-linux-ppc64': 1.2.4
    +      '@img/sharp-libvips-linux-riscv64': 1.2.4
    +      '@img/sharp-libvips-linux-s390x': 1.2.4
    +      '@img/sharp-libvips-linux-x64': 1.2.4
    +      '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
    +      '@img/sharp-libvips-linuxmusl-x64': 1.2.4
    +      '@img/sharp-linux-arm': 0.34.5
    +      '@img/sharp-linux-arm64': 0.34.5
    +      '@img/sharp-linux-ppc64': 0.34.5
    +      '@img/sharp-linux-riscv64': 0.34.5
    +      '@img/sharp-linux-s390x': 0.34.5
    +      '@img/sharp-linux-x64': 0.34.5
    +      '@img/sharp-linuxmusl-arm64': 0.34.5
    +      '@img/sharp-linuxmusl-x64': 0.34.5
    +      '@img/sharp-wasm32': 0.34.5
    +      '@img/sharp-win32-arm64': 0.34.5
    +      '@img/sharp-win32-ia32': 0.34.5
    +      '@img/sharp-win32-x64': 0.34.5
    +
    +  siginfo@2.0.0: {}
    +
    +  source-map-js@1.2.1: {}
    +
    +  stackback@0.0.2: {}
    +
    +  std-env@3.10.0: {}
    +
    +  supports-color@10.2.2: {}
    +
    +  tinybench@2.9.0: {}
    +
    +  tinyexec@0.3.2: {}
    +
    +  tinypool@1.1.1: {}
    +
    +  tinyrainbow@1.2.0: {}
    +
    +  tinyspy@3.0.2: {}
    +
    +  to-buffer@1.2.2:
    +    dependencies:
    +      isarray: 2.0.5
    +      safe-buffer: 5.2.1
    +      typed-array-buffer: 1.0.3
    +
    +  toml@3.0.0: {}
    +
    +  tslib@2.8.1:
    +    optional: true
    +
    +  typed-array-buffer@1.0.3:
    +    dependencies:
    +      call-bound: 1.0.4
    +      es-errors: 1.3.0
    +      is-typed-array: 1.1.15
    +
    +  typescript@5.9.3: {}
    +
    +  undici@7.28.0: {}
    +
    +  unenv@2.0.0-rc.24:
    +    dependencies:
    +      pathe: 2.0.3
    +
    +  urijs@1.19.11: {}
    +
    +  vite-node@2.1.9:
    +    dependencies:
    +      cac: 6.7.14
    +      debug: 4.4.3
    +      es-module-lexer: 1.7.0
    +      pathe: 1.1.2
    +      vite: 5.4.21
    +    transitivePeerDependencies:
    +      - '@types/node'
    +      - less
    +      - lightningcss
    +      - sass
    +      - sass-embedded
    +      - stylus
    +      - sugarss
    +      - supports-color
    +      - terser
    +
    +  vite@5.4.21:
    +    dependencies:
    +      esbuild: 0.21.5
    +      postcss: 8.5.17
    +      rollup: 4.62.2
    +    optionalDependencies:
    +      fsevents: 2.3.3
    +
    +  vitest@2.1.9:
    +    dependencies:
    +      '@vitest/expect': 2.1.9
    +      '@vitest/mocker': 2.1.9(vite@5.4.21)
    +      '@vitest/pretty-format': 2.1.9
    +      '@vitest/runner': 2.1.9
    +      '@vitest/snapshot': 2.1.9
    +      '@vitest/spy': 2.1.9
    +      '@vitest/utils': 2.1.9
    +      chai: 5.3.3
    +      debug: 4.4.3
    +      expect-type: 1.4.0
    +      magic-string: 0.30.21
    +      pathe: 1.1.2
    +      std-env: 3.10.0
    +      tinybench: 2.9.0
    +      tinyexec: 0.3.2
    +      tinypool: 1.1.1
    +      tinyrainbow: 1.2.0
    +      vite: 5.4.21
    +      vite-node: 2.1.9
    +      why-is-node-running: 2.3.0
    +    transitivePeerDependencies:
    +      - less
    +      - lightningcss
    +      - msw
    +      - sass
    +      - sass-embedded
    +      - stylus
    +      - sugarss
    +      - supports-color
    +      - terser
    +
    +  which-typed-array@1.1.22:
    +    dependencies:
    +      available-typed-arrays: 1.0.7
    +      call-bind: 1.0.9
    +      call-bound: 1.0.4
    +      for-each: 0.3.5
    +      get-proto: 1.0.1
    +      gopd: 1.2.0
    +      has-tostringtag: 1.0.2
    +
    +  why-is-node-running@2.3.0:
    +    dependencies:
    +      siginfo: 2.0.0
    +      stackback: 0.0.2
    +
    +  workerd@1.20260708.1:
    +    optionalDependencies:
    +      '@cloudflare/workerd-darwin-64': 1.20260708.1
    +      '@cloudflare/workerd-darwin-arm64': 1.20260708.1
    +      '@cloudflare/workerd-linux-64': 1.20260708.1
    +      '@cloudflare/workerd-linux-arm64': 1.20260708.1
    +      '@cloudflare/workerd-windows-64': 1.20260708.1
    +
    +  wrangler@4.110.0(@cloudflare/workers-types@4.20260702.1):
    +    dependencies:
    +      '@cloudflare/kv-asset-handler': 0.5.0
    +      '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1)
    +      blake3-wasm: 2.1.5
    +      esbuild: 0.28.1
    +      miniflare: 4.20260708.1
    +      path-to-regexp: 6.3.0
    +      unenv: 2.0.0-rc.24
    +      workerd: 1.20260708.1
    +    optionalDependencies:
    +      '@cloudflare/workers-types': 4.20260702.1
    +      fsevents: 2.3.3
    +    transitivePeerDependencies:
    +      - bufferutil
    +      - utf-8-validate
    +
    +  ws@8.21.0: {}
    +
    +  youch-core@0.3.3:
    +    dependencies:
    +      '@poppinss/exception': 1.2.3
    +      error-stack-parser-es: 1.0.5
    +
    +  youch@4.1.0-beta.10:
    +    dependencies:
    +      '@poppinss/colors': 4.1.6
    +      '@poppinss/dumper': 0.6.5
    +      '@speed-highlight/core': 1.2.17
    +      cookie: 1.1.1
    +      youch-core: 0.3.3
    diff --git a/relayer-proxy/src/constants.ts b/relayer-proxy/src/constants.ts
    new file mode 100644
    index 0000000..1f70d4b
    --- /dev/null
    +++ b/relayer-proxy/src/constants.ts
    @@ -0,0 +1,63 @@
    +/**
    + * Constants for the passkey-kit Relayer Proxy.
    + */
    +
    +// ============================================================================
    +// Service Metadata
    +// ============================================================================
    +
    +/** Service name for health checks */
    +export const SERVICE_NAME = "passkey-kit-relayer-proxy";
    +
    +// ============================================================================
    +// Network URLs
    +// ============================================================================
    +
    +/** Stellar Friendbot URL for testnet funding */
    +export const FRIENDBOT_URL = "https://friendbot.stellar.org";
    +
    +// ============================================================================
    +// API Key Configuration
    +// ============================================================================
    +
    +/** KV storage key prefix for API keys */
    +export const API_KEY_PREFIX = "api-key:";
    +
    +/** Possible field names for the API key in the /gen response */
    +export const API_KEY_FIELD_NAMES = ["apiKey", "api_key", "key", "token"] as const;
    +
    +/** Minimum length for a valid API key response */
    +export const API_KEY_MIN_LENGTH = 10;
    +
    +/** Maximum length for a valid API key response */
    +export const API_KEY_MAX_LENGTH = 200;
    +
    +// ============================================================================
    +// Stellar Address Validation
    +// ============================================================================
    +
    +/** Length of a Stellar account address (G-address), excluding the leading G */
    +export const STELLAR_ADDRESS_LENGTH = 55;
    +
    +/** Regex pattern for extracting a missing account from a Relayer error */
    +export const MISSING_ACCOUNT_PATTERN = /Account not found:\s*(G[A-Z0-9]{55})/;
    +
    +// ============================================================================
    +// Retry Configuration
    +// ============================================================================
    +
    +/** How long to retry on testnet when channel accounts need funding (5 minutes) */
    +export const TESTNET_RETRY_DURATION_MS = 5 * 60 * 1000;
    +
    +// ============================================================================
    +// HTTP Headers
    +// ============================================================================
    +
    +export const IP_HEADERS = {
    +  CF_CONNECTING_IP: "CF-Connecting-IP",
    +  X_FORWARDED_FOR: "X-Forwarded-For",
    +  X_REAL_IP: "X-Real-IP",
    +} as const;
    +
    +/** Default IP value when none can be determined */
    +export const UNKNOWN_IP = "unknown";
    diff --git a/relayer-proxy/src/index.test.ts b/relayer-proxy/src/index.test.ts
    new file mode 100644
    index 0000000..f24d1ce
    --- /dev/null
    +++ b/relayer-proxy/src/index.test.ts
    @@ -0,0 +1,513 @@
    +import { afterEach, describe, expect, it, vi } from "vitest";
    +import {
    +  PluginExecutionError,
    +  PluginTransportError,
    +} from "@openzeppelin/relayer-plugin-channels";
    +import worker, { extractMissingAccount, getClientIP } from "./index";
    +import { SERVICE_NAME } from "./constants";
    +
    +// ---------------------------------------------------------------------------
    +// Mock the Channels plugin: a stub ChannelsClient whose two submit methods are
    +// controllable vi.fns, plus the two error classes the handler maps by instance.
    +// ---------------------------------------------------------------------------
    +const { submitSorobanTransaction, submitTransaction } = vi.hoisted(() => ({
    +  submitSorobanTransaction: vi.fn(),
    +  submitTransaction: vi.fn(),
    +}));
    +
    +vi.mock("@openzeppelin/relayer-plugin-channels", () => {
    +  class PluginExecutionError extends Error {
    +    errorDetails?: { code?: unknown; details?: unknown };
    +    constructor(
    +      message: string,
    +      errorDetails?: { code?: unknown; details?: unknown }
    +    ) {
    +      super(message);
    +      this.name = "PluginExecutionError";
    +      this.errorDetails = errorDetails;
    +    }
    +  }
    +  class PluginTransportError extends Error {
    +    statusCode?: number;
    +    constructor(message: string, statusCode?: number) {
    +      super(message);
    +      this.name = "PluginTransportError";
    +      this.statusCode = statusCode;
    +    }
    +  }
    +  class ChannelsClient {
    +    constructor(_opts: unknown) {}
    +    submitSorobanTransaction = submitSorobanTransaction;
    +    submitTransaction = submitTransaction;
    +  }
    +  return { ChannelsClient, PluginExecutionError, PluginTransportError };
    +});
    +
    +// ---------------------------------------------------------------------------
    +// Test doubles
    +// ---------------------------------------------------------------------------
    +const SEEDED_KEY = "sk_seededapikey123456";
    +const CLIENT_IP = "203.0.113.7";
    +const KV_KEY = `api-key:${CLIENT_IP}`;
    +
    +function createKV(initial: Record = {}) {
    +  const store = new Map(Object.entries(initial));
    +  return {
    +    store,
    +    get: vi.fn(async (k: string) => (store.has(k) ? store.get(k)! : null)),
    +    put: vi.fn(async (k: string, v: string) => {
    +      store.set(k, v);
    +    }),
    +    delete: vi.fn(async (k: string) => {
    +      store.delete(k);
    +    }),
    +  };
    +}
    +
    +type FakeKV = ReturnType;
    +
    +function makeEnv(opts: { kv?: FakeKV; network?: "testnet" | "mainnet" } = {}) {
    +  const network = opts.network ?? "testnet";
    +  return {
    +    API_KEYS: opts.kv ?? createKV(),
    +    NETWORK: network,
    +    RELAYER_BASE_URL:
    +      network === "mainnet"
    +        ? "https://channels.openzeppelin.com"
    +        : "https://channels.openzeppelin.com/testnet",
    +  } as any;
    +}
    +
    +function seededKV() {
    +  return createKV({
    +    [KV_KEY]: JSON.stringify({ apiKey: SEEDED_KEY, createdAt: 1000 }),
    +  });
    +}
    +
    +const ctx = () =>
    +  ({ waitUntil: vi.fn(), passThroughOnException: vi.fn() }) as any;
    +
    +function makeRequest(
    +  path: string,
    +  opts: { method?: string; body?: unknown; ip?: string } = {}
    +) {
    +  const headers: Record = {
    +    "CF-Connecting-IP": opts.ip ?? CLIENT_IP,
    +  };
    +  const init: RequestInit = { method: opts.method ?? "GET", headers };
    +  if (opts.body !== undefined) {
    +    headers["Content-Type"] = "application/json";
    +    init.body =
    +      typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
    +  }
    +  return new Request(`http://localhost${path}`, init);
    +}
    +
    +function okJson(obj: unknown) {
    +  return { ok: true, status: 200, text: async () => JSON.stringify(obj) };
    +}
    +function okText(text: string) {
    +  return { ok: true, status: 200, text: async () => text };
    +}
    +function notOk(status = 500, text = "error") {
    +  return { ok: false, status, text: async () => text };
    +}
    +
    +function stubFetch(handlers: {
    +  gen?: () => unknown;
    +  friendbot?: () => unknown;
    +}) {
    +  const fetchMock = vi.fn(async (input: unknown) => {
    +    const url = typeof input === "string" ? input : (input as Request).url;
    +    if (url.includes("/gen")) return handlers.gen?.() ?? okJson({});
    +    if (url.includes("friendbot")) return handlers.friendbot?.() ?? okText("ok");
    +    throw new Error(`Unexpected fetch: ${url}`);
    +  });
    +  vi.stubGlobal("fetch", fetchMock);
    +  return fetchMock;
    +}
    +
    +const G_ADDRESS = `G${"A".repeat(55)}`;
    +
    +afterEach(() => {
    +  vi.clearAllMocks();
    +  vi.unstubAllGlobals();
    +});
    +
    +// ---------------------------------------------------------------------------
    +// Pure helpers
    +// ---------------------------------------------------------------------------
    +describe("getClientIP", () => {
    +  it("prefers CF-Connecting-IP, then X-Forwarded-For, then X-Real-IP", () => {
    +    expect(
    +      getClientIP(
    +        new Request("http://x", { headers: { "CF-Connecting-IP": "1.1.1.1" } })
    +      )
    +    ).toBe("1.1.1.1");
    +    expect(
    +      getClientIP(
    +        new Request("http://x", {
    +          headers: { "X-Forwarded-For": "2.2.2.2, 3.3.3.3" },
    +        })
    +      )
    +    ).toBe("2.2.2.2");
    +    expect(
    +      getClientIP(
    +        new Request("http://x", { headers: { "X-Real-IP": "4.4.4.4" } })
    +      )
    +    ).toBe("4.4.4.4");
    +  });
    +
    +  it("falls back to 'unknown' with no IP headers", () => {
    +    expect(getClientIP(new Request("http://x"))).toBe("unknown");
    +  });
    +});
    +
    +describe("extractMissingAccount", () => {
    +  it("pulls the G-address out of a not-found error", () => {
    +    expect(extractMissingAccount(`Account not found: ${G_ADDRESS}`)).toBe(
    +      G_ADDRESS
    +    );
    +  });
    +
    +  it("returns null when the message has no account", () => {
    +    expect(extractMissingAccount("some other failure")).toBeNull();
    +  });
    +});
    +
    +// ---------------------------------------------------------------------------
    +// Health
    +// ---------------------------------------------------------------------------
    +describe("GET /", () => {
    +  it("reports service and network", async () => {
    +    const res = await worker.fetch(makeRequest("/"), makeEnv(), ctx());
    +    expect(res.status).toBe(200);
    +    await expect(res.json()).resolves.toEqual({
    +      status: "ok",
    +      service: SERVICE_NAME,
    +      network: "testnet",
    +    });
    +  });
    +});
    +
    +// ---------------------------------------------------------------------------
    +// POST / — mode validation
    +// ---------------------------------------------------------------------------
    +describe("POST / validation", () => {
    +  it("rejects a request with neither xdr nor func+auth (400)", async () => {
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: {} }),
    +      makeEnv({ kv: seededKV() }),
    +      ctx()
    +    );
    +    expect(res.status).toBe(400);
    +    await expect(res.json()).resolves.toEqual({
    +      success: false,
    +      error: "Request must include 'xdr' OR ('func' and 'auth')",
    +    });
    +    expect(submitSorobanTransaction).not.toHaveBeenCalled();
    +    expect(submitTransaction).not.toHaveBeenCalled();
    +  });
    +
    +  it("rejects a request with both xdr and func+auth (400)", async () => {
    +    const res = await worker.fetch(
    +      makeRequest("/", {
    +        method: "POST",
    +        body: { xdr: "AAA", func: "BBB", auth: ["CCC"] },
    +      }),
    +      makeEnv({ kv: seededKV() }),
    +      ctx()
    +    );
    +    expect(res.status).toBe(400);
    +    await expect(res.json()).resolves.toEqual({
    +      success: false,
    +      error: "Request must include 'xdr' OR ('func' and 'auth'), not both",
    +    });
    +  });
    +
    +  it("rejects malformed JSON (400)", async () => {
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: "{not json" }),
    +      makeEnv({ kv: seededKV() }),
    +      ctx()
    +    );
    +    expect(res.status).toBe(400);
    +    await expect(res.json()).resolves.toEqual({
    +      success: false,
    +      error: "Invalid JSON body",
    +    });
    +  });
    +});
    +
    +// ---------------------------------------------------------------------------
    +// POST / — submission paths
    +// ---------------------------------------------------------------------------
    +describe("POST / submission", () => {
    +  it("submits a Soroban transaction (func+auth) and returns the result", async () => {
    +    stubFetch({});
    +    submitSorobanTransaction.mockResolvedValueOnce({
    +      transactionId: "tx1",
    +      hash: "h1",
    +      status: "pending",
    +    });
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", {
    +        method: "POST",
    +        body: { func: "FUNC", auth: ["A1", "A2"] },
    +      }),
    +      makeEnv({ kv: seededKV() }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(200);
    +    await expect(res.json()).resolves.toEqual({
    +      success: true,
    +      data: { transactionId: "tx1", hash: "h1", status: "pending" },
    +    });
    +    expect(submitSorobanTransaction).toHaveBeenCalledWith({
    +      func: "FUNC",
    +      auth: ["A1", "A2"],
    +    });
    +    expect(submitTransaction).not.toHaveBeenCalled();
    +  });
    +
    +  it("fee-bumps a signed transaction (xdr) and returns the result", async () => {
    +    submitTransaction.mockResolvedValueOnce({
    +      transactionId: "tx2",
    +      hash: "h2",
    +      status: "submitted",
    +    });
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { xdr: "XDR" } }),
    +      makeEnv({ kv: seededKV() }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(200);
    +    await expect(res.json()).resolves.toEqual({
    +      success: true,
    +      data: { transactionId: "tx2", hash: "h2", status: "submitted" },
    +    });
    +    expect(submitTransaction).toHaveBeenCalledWith({ xdr: "XDR" });
    +    expect(submitSorobanTransaction).not.toHaveBeenCalled();
    +  });
    +
    +  it("maps PluginExecutionError to 400 with code/details", async () => {
    +    submitSorobanTransaction.mockRejectedValueOnce(
    +      new PluginExecutionError("exec failed", { code: "E123", details: "nope" })
    +    );
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    +      makeEnv({ kv: seededKV() }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(400);
    +    await expect(res.json()).resolves.toEqual({
    +      success: false,
    +      error: "exec failed",
    +      data: { code: "E123", details: "nope" },
    +    });
    +  });
    +
    +  it("maps PluginTransportError to its status code", async () => {
    +    submitSorobanTransaction.mockRejectedValueOnce(
    +      new PluginTransportError("upstream unavailable", 502)
    +    );
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    +      makeEnv({ kv: seededKV() }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(502);
    +    await expect(res.json()).resolves.toEqual({
    +      success: false,
    +      error: "upstream unavailable",
    +    });
    +  });
    +
    +  it("maps an unknown error to 500", async () => {
    +    submitSorobanTransaction.mockRejectedValueOnce(new Error("boom"));
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    +      makeEnv({ kv: seededKV() }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(500);
    +    await expect(res.json()).resolves.toEqual({
    +      success: false,
    +      error: "boom",
    +    });
    +  });
    +});
    +
    +// ---------------------------------------------------------------------------
    +// POST / — testnet friendbot retry
    +// ---------------------------------------------------------------------------
    +describe("POST / testnet retry", () => {
    +  it("funds a missing channel account via friendbot then retries", async () => {
    +    const fetchMock = stubFetch({ friendbot: () => okText("funded") });
    +    submitSorobanTransaction
    +      .mockRejectedValueOnce(new Error(`Account not found: ${G_ADDRESS}`))
    +      .mockResolvedValueOnce({ transactionId: "tx3", hash: "h3", status: "ok" });
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    +      makeEnv({ kv: seededKV(), network: "testnet" }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(200);
    +    await expect(res.json()).resolves.toEqual({
    +      success: true,
    +      data: { transactionId: "tx3", hash: "h3", status: "ok" },
    +    });
    +    expect(submitSorobanTransaction).toHaveBeenCalledTimes(2);
    +    const friendbotCalls = fetchMock.mock.calls.filter(([u]) =>
    +      String(u).includes("friendbot")
    +    );
    +    expect(friendbotCalls).toHaveLength(1);
    +    expect(String(friendbotCalls[0][0])).toContain(G_ADDRESS);
    +  });
    +
    +  it("does NOT retry a missing account on mainnet (no friendbot)", async () => {
    +    const fetchMock = stubFetch({});
    +    submitSorobanTransaction.mockRejectedValueOnce(
    +      new Error(`Account not found: ${G_ADDRESS}`)
    +    );
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    +      makeEnv({ kv: seededKV(), network: "mainnet" }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(500);
    +    expect(submitSorobanTransaction).toHaveBeenCalledTimes(1);
    +    expect(
    +      fetchMock.mock.calls.some(([u]) => String(u).includes("friendbot"))
    +    ).toBe(false);
    +  });
    +});
    +
    +// ---------------------------------------------------------------------------
    +// Per-IP API key lifecycle
    +// ---------------------------------------------------------------------------
    +describe("API key lifecycle", () => {
    +  it("mints a key from the Relayer /gen endpoint when none exists", async () => {
    +    const kv = createKV();
    +    const fetchMock = stubFetch({
    +      gen: () => okJson({ apiKey: "sk_generatedkey1234" }),
    +    });
    +    submitSorobanTransaction.mockResolvedValueOnce({
    +      transactionId: "tx",
    +      hash: "h",
    +      status: "ok",
    +    });
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    +      makeEnv({ kv }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(200);
    +    expect(fetchMock.mock.calls.some(([u]) => String(u).includes("/gen"))).toBe(
    +      true
    +    );
    +    const stored = JSON.parse(kv.store.get(KV_KEY)!);
    +    expect(stored.apiKey).toBe("sk_generatedkey1234");
    +    expect(typeof stored.createdAt).toBe("number");
    +  });
    +
    +  it("returns 500 when a key cannot be minted", async () => {
    +    stubFetch({ gen: () => notOk(500, "denied") });
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    +      makeEnv({ kv: createKV() }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(500);
    +    await expect(res.json()).resolves.toEqual({
    +      success: false,
    +      error: "Could not obtain API key. Service may be misconfigured.",
    +    });
    +    expect(submitSorobanTransaction).not.toHaveBeenCalled();
    +  });
    +
    +  it("migrates a legacy plain-text KV value to the JSON record", async () => {
    +    const legacyKey = "sk_plaintextkey12345";
    +    const kv = createKV({ [KV_KEY]: legacyKey });
    +    stubFetch({});
    +    submitSorobanTransaction.mockResolvedValueOnce({
    +      transactionId: "tx",
    +      hash: "h",
    +      status: "ok",
    +    });
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    +      makeEnv({ kv }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(200);
    +    // Reused the legacy key (no /gen call) and rewrote it as a JSON record.
    +    const stored = JSON.parse(kv.store.get(KV_KEY)!);
    +    expect(stored.apiKey).toBe(legacyKey);
    +    expect(typeof stored.createdAt).toBe("number");
    +  });
    +
    +  it("migrates a legacy JSON-string KV value to the JSON record", async () => {
    +    const legacyKey = "sk_jsonstringkey1234";
    +    const kv = createKV({ [KV_KEY]: JSON.stringify(legacyKey) });
    +    stubFetch({});
    +    submitSorobanTransaction.mockResolvedValueOnce({
    +      transactionId: "tx",
    +      hash: "h",
    +      status: "ok",
    +    });
    +
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    +      makeEnv({ kv }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(200);
    +    const stored = JSON.parse(kv.store.get(KV_KEY)!);
    +    expect(stored.apiKey).toBe(legacyKey);
    +  });
    +});
    +
    +// ---------------------------------------------------------------------------
    +// GET /status
    +// ---------------------------------------------------------------------------
    +describe("GET /status", () => {
    +  it("returns client IP, network, and key presence", async () => {
    +    const res = await worker.fetch(
    +      makeRequest("/status"),
    +      makeEnv({ kv: seededKV() }),
    +      ctx()
    +    );
    +    expect(res.status).toBe(200);
    +    await expect(res.json()).resolves.toEqual({
    +      success: true,
    +      data: {
    +        clientIP: CLIENT_IP,
    +        network: "testnet",
    +        hasKey: true,
    +        keyCreatedAt: 1000,
    +      },
    +    });
    +  });
    +});
    diff --git a/relayer-proxy/src/index.ts b/relayer-proxy/src/index.ts
    new file mode 100644
    index 0000000..cd20174
    --- /dev/null
    +++ b/relayer-proxy/src/index.ts
    @@ -0,0 +1,528 @@
    +/**
    + * passkey-kit Relayer Proxy — Cloudflare Worker
    + *
    + * A keyless proxy in front of the OpenZeppelin Relayer Channels service. It
    + * mints and caches one Relayer API key per client IP so the browser can submit
    + * fee-sponsored transactions with ZERO secrets in the bundle (this replaces
    + * passkey-kit's old defect of inlining VITE_relayerApiKey into client JS).
    + *
    + * Uses the official `@openzeppelin/relayer-plugin-channels` SDK.
    + *
    + * Features:
    + * - Automatic API-key generation per IP (persisted indefinitely in KV).
    + * - One API key per IP; the Relayer's usage limits reset every 24h on its side.
    + * - Two submission modes: `{ func, auth }` (Address credentials) and `{ xdr }`
    + *   (source-account auth, e.g. deploys).
    + * - Testnet: funds missing channel accounts via Friendbot and retries.
    + * - Separate testnet (default) and mainnet (`--env production`) deployments.
    + */
    +
    +import { Hono } from "hono";
    +import { cors } from "hono/cors";
    +import {
    +  ChannelsClient,
    +  PluginExecutionError,
    +  PluginTransportError,
    +} from "@openzeppelin/relayer-plugin-channels";
    +import {
    +  SERVICE_NAME,
    +  FRIENDBOT_URL,
    +  API_KEY_PREFIX,
    +  API_KEY_FIELD_NAMES,
    +  API_KEY_MIN_LENGTH,
    +  API_KEY_MAX_LENGTH,
    +  MISSING_ACCOUNT_PATTERN,
    +  TESTNET_RETRY_DURATION_MS,
    +  IP_HEADERS,
    +  UNKNOWN_IP,
    +} from "./constants";
    +
    +interface StoredApiKey {
    +  apiKey: string;
    +  createdAt: number;
    +}
    +
    +interface ApiKeyReadResult {
    +  storedKey: StoredApiKey;
    +  needsMigration: boolean;
    +}
    +
    +// Hono app
    +const app = new Hono<{ Bindings: Env }>();
    +
    +// Enable CORS
    +app.use("*", cors());
    +
    +app.onError((error, c) => {
    +  console.error("Unhandled worker error:", error);
    +  return c.json(
    +    {
    +      success: false,
    +      error: "Internal server error",
    +    },
    +    500
    +  );
    +});
    +
    +// ============================================================================
    +// Helper Functions
    +// ============================================================================
    +
    +/**
    + * Get the client IP from the request, preferring Cloudflare's trusted header.
    + */
    +export function getClientIP(request: Request): string {
    +  return (
    +    request.headers.get(IP_HEADERS.CF_CONNECTING_IP) ||
    +    request.headers.get(IP_HEADERS.X_FORWARDED_FOR)?.split(",")[0]?.trim() ||
    +    request.headers.get(IP_HEADERS.X_REAL_IP) ||
    +    UNKNOWN_IP
    +  );
    +}
    +
    +/**
    + * Generate a unique KV key for an IP.
    + */
    +function getKVKey(ip: string): string {
    +  return `${API_KEY_PREFIX}${ip}`;
    +}
    +
    +/**
    + * Validate API key format before use.
    + */
    +function isValidApiKey(apiKey: string): boolean {
    +  const trimmed = apiKey.trim();
    +  return (
    +    trimmed.length >= API_KEY_MIN_LENGTH && trimmed.length <= API_KEY_MAX_LENGTH
    +  );
    +}
    +
    +/**
    + * Runtime type guard for stored API key records.
    + */
    +function isStoredApiKey(value: unknown): value is StoredApiKey {
    +  if (!value || typeof value !== "object") {
    +    return false;
    +  }
    +
    +  const candidate = value as Partial;
    +  return (
    +    typeof candidate.apiKey === "string" &&
    +    typeof candidate.createdAt === "number" &&
    +    isValidApiKey(candidate.apiKey)
    +  );
    +}
    +
    +/**
    + * Read and normalize a stored API key record.
    + *
    + * Supports legacy plain-text / JSON-string values and migrates them lazily.
    + */
    +async function readStoredApiKey(
    +  env: Env,
    +  kvKey: string
    +): Promise {
    +  try {
    +    const raw = await env.API_KEYS.get(kvKey);
    +    if (!raw) {
    +      return null;
    +    }
    +
    +    const trimmed = raw.trim();
    +    if (!trimmed) {
    +      console.error(`Empty API key value in KV for ${kvKey}. Deleting record.`);
    +      await env.API_KEYS.delete(kvKey);
    +      return null;
    +    }
    +
    +    try {
    +      const parsed = JSON.parse(trimmed) as unknown;
    +
    +      if (isStoredApiKey(parsed)) {
    +        return { storedKey: parsed, needsMigration: false };
    +      }
    +
    +      // Backward compatibility: JSON string value, e.g. "\"sk_...\""
    +      if (typeof parsed === "string" && isValidApiKey(parsed)) {
    +        return {
    +          storedKey: {
    +            apiKey: parsed.trim(),
    +            createdAt: Date.now(),
    +          },
    +          needsMigration: true,
    +        };
    +      }
    +    } catch {
    +      // Backward compatibility: plain text value, e.g. "sk_..."
    +      if (isValidApiKey(trimmed)) {
    +        return {
    +          storedKey: {
    +            apiKey: trimmed,
    +            createdAt: Date.now(),
    +          },
    +          needsMigration: true,
    +        };
    +      }
    +    }
    +
    +    console.error(
    +      `Corrupted API key KV value for ${kvKey}. Deleting invalid record.`
    +    );
    +    await env.API_KEYS.delete(kvKey);
    +    return null;
    +  } catch (error) {
    +    console.error(`Failed reading API key from KV for ${kvKey}:`, error);
    +    return null;
    +  }
    +}
    +
    +/**
    + * Get or generate an API key for the given IP.
    + * Keys are stored indefinitely — one key per IP address.
    + * The Relayer's usage limits reset every 24 hours on their side.
    + */
    +async function getOrCreateApiKey(
    +  env: Env,
    +  ip: string
    +): Promise<{ apiKey: string; isNew: boolean } | null> {
    +  const kvKey = getKVKey(ip);
    +
    +  // Check if we already have an API key for this IP.
    +  const cached = await readStoredApiKey(env, kvKey);
    +  if (cached) {
    +    if (cached.needsMigration) {
    +      try {
    +        await env.API_KEYS.put(kvKey, JSON.stringify(cached.storedKey));
    +      } catch (error) {
    +        console.error(
    +          `Failed migrating legacy API key format for ${kvKey}:`,
    +          error
    +        );
    +      }
    +    }
    +
    +    return { apiKey: cached.storedKey.apiKey, isNew: false };
    +  }
    +
    +  // No existing key — generate a new one from the Relayer's /gen endpoint.
    +  const newApiKey = await generateApiKey(env);
    +  if (!newApiKey) {
    +    return null;
    +  }
    +
    +  const storedKey: StoredApiKey = {
    +    apiKey: newApiKey,
    +    createdAt: Date.now(),
    +  };
    +
    +  // Store without expiration TTL — key persists until manually deleted.
    +  try {
    +    await env.API_KEYS.put(kvKey, JSON.stringify(storedKey));
    +  } catch (error) {
    +    console.error(`Failed storing API key in KV for ${kvKey}:`, error);
    +    return null;
    +  }
    +
    +  return { apiKey: newApiKey, isNew: true };
    +}
    +
    +/**
    + * Generate a new API key from the Relayer service. The /gen endpoint requires
    + * no authentication (GET).
    + * @see https://docs.openzeppelin.com/relayer/1.5.x/guides/stellar-channels-guide
    + */
    +async function generateApiKey(env: Env): Promise {
    +  try {
    +    const response = await fetch(`${env.RELAYER_BASE_URL}/gen`, {
    +      method: "GET",
    +    });
    +
    +    const text = await response.text();
    +
    +    if (response.ok) {
    +      try {
    +        const data = JSON.parse(text) as Record;
    +        const apiKey = API_KEY_FIELD_NAMES.map((name) => data[name]).find(
    +          (v) => v
    +        );
    +        if (typeof apiKey === "string") {
    +          return apiKey;
    +        }
    +        console.error("API key not found in response:", data);
    +        return null;
    +      } catch {
    +        // Response might be a plain-text API key.
    +        if (
    +          text &&
    +          text.length > API_KEY_MIN_LENGTH &&
    +          text.length < API_KEY_MAX_LENGTH
    +        ) {
    +          return text.trim();
    +        }
    +        console.error("Could not parse API key response:", text);
    +        return null;
    +      }
    +    }
    +
    +    console.error("Failed to generate API key:", response.status, text);
    +    return null;
    +  } catch (error) {
    +    console.error("Error generating API key:", error);
    +    return null;
    +  }
    +}
    +
    +/**
    + * Create a ChannelsClient for the configured network.
    + */
    +function createClient(env: Env, apiKey: string): ChannelsClient {
    +  return new ChannelsClient({
    +    baseUrl: env.RELAYER_BASE_URL,
    +    apiKey,
    +  });
    +}
    +
    +/**
    + * Extract the account address from an "Account not found" error message.
    + */
    +export function extractMissingAccount(errorMessage: string): string | null {
    +  const match = errorMessage.match(MISSING_ACCOUNT_PATTERN);
    +  return match ? match[1] : null;
    +}
    +
    +/**
    + * Fund an account via Friendbot (testnet only).
    + */
    +async function fundWithFriendbot(account: string): Promise {
    +  try {
    +    const response = await fetch(
    +      `${FRIENDBOT_URL}?addr=${encodeURIComponent(account)}`
    +    );
    +    return response.ok;
    +  } catch (error) {
    +    console.error("Friendbot funding failed:", error);
    +    return false;
    +  }
    +}
    +
    +// ============================================================================
    +// API Endpoints
    +// ============================================================================
    +
    +// Health check
    +app.get("/", (c) => {
    +  return c.json({
    +    status: "ok",
    +    service: SERVICE_NAME,
    +    network: c.env.NETWORK,
    +  });
    +});
    +
    +/**
    + * Submit a transaction via the Relayer.
    + * POST /
    + *
    + * Two modes:
    + * 1. { func: string, auth: string[] } — Relayer builds the tx with channel
    + *    accounts (Address credentials: transfers, wallet operations).
    + * 2. { xdr: string } — Relayer fee-bumps an already-signed transaction
    + *    (source-account auth: deploys).
    + *
    + * On testnet, if channel accounts are missing (after a testnet reset), fund
    + * them via Friendbot and retry for up to 5 minutes.
    + */
    +app.post("/", async (c) => {
    +  try {
    +    const ip = getClientIP(c.req.raw);
    +    const apiKeyResult = await getOrCreateApiKey(c.env, ip);
    +
    +    if (!apiKeyResult) {
    +      return c.json(
    +        {
    +          success: false,
    +          error: "Could not obtain API key. Service may be misconfigured.",
    +        },
    +        500
    +      );
    +    }
    +
    +    const body = await c.req.json<{
    +      func?: string;
    +      auth?: string[];
    +      xdr?: string;
    +    }>();
    +
    +    // Validate: must have either xdr OR (func AND auth).
    +    const hasXdr = !!body.xdr;
    +    const hasFuncAuth = !!body.func && !!body.auth;
    +
    +    if (!hasXdr && !hasFuncAuth) {
    +      return c.json(
    +        {
    +          success: false,
    +          error: "Request must include 'xdr' OR ('func' and 'auth')",
    +        },
    +        400
    +      );
    +    }
    +
    +    if (hasXdr && hasFuncAuth) {
    +      return c.json(
    +        {
    +          success: false,
    +          error: "Request must include 'xdr' OR ('func' and 'auth'), not both",
    +        },
    +        400
    +      );
    +    }
    +
    +    const client = createClient(c.env, apiKeyResult.apiKey);
    +    const isTestnet = c.env.NETWORK === "testnet";
    +
    +    // On testnet, retry for up to 5 minutes to handle channel accounts needing
    +    // funding. On mainnet, only try once (no Friendbot). Network wait time does
    +    // not count toward the worker CPU limit.
    +    const deadline = isTestnet ? Date.now() + TESTNET_RETRY_DURATION_MS : 0;
    +    const fundedAccounts = new Set();
    +
    +    while (true) {
    +      try {
    +        const result = hasXdr
    +          ? await client.submitTransaction({ xdr: body.xdr! })
    +          : await client.submitSorobanTransaction({
    +              func: body.func!,
    +              auth: body.auth!,
    +            });
    +
    +        return c.json({
    +          success: true,
    +          data: {
    +            transactionId: result.transactionId,
    +            hash: result.hash,
    +            status: result.status,
    +          },
    +        });
    +      } catch (submitError) {
    +        const errorMessage =
    +          submitError instanceof Error
    +            ? submitError.message
    +            : String(submitError);
    +
    +        const missingAccount = extractMissingAccount(errorMessage);
    +        const timeRemaining = deadline - Date.now();
    +
    +        if (missingAccount && isTestnet && timeRemaining > 0) {
    +          if (!fundedAccounts.has(missingAccount)) {
    +            console.log(
    +              `Account ${missingAccount} not found. Funding via friendbot (${Math.round(
    +                timeRemaining / 1000
    +              )}s remaining)...`
    +            );
    +
    +            const funded = await fundWithFriendbot(missingAccount);
    +            if (funded) {
    +              console.log(
    +                `Successfully funded ${missingAccount}. Retrying submission...`
    +              );
    +              fundedAccounts.add(missingAccount);
    +            } else {
    +              console.error(`Failed to fund ${missingAccount}`);
    +            }
    +          } else {
    +            console.log(`Account ${missingAccount} already funded, retrying...`);
    +          }
    +
    +          continue; // Retry immediately.
    +        }
    +
    +        // Not a recoverable error or deadline exceeded — throw to outer handler.
    +        throw submitError;
    +      }
    +    }
    +  } catch (error) {
    +    console.error("Relayer submission error:", error);
    +
    +    if (error instanceof SyntaxError) {
    +      return c.json(
    +        {
    +          success: false,
    +          error: "Invalid JSON body",
    +        },
    +        400
    +      );
    +    }
    +
    +    if (error instanceof PluginExecutionError) {
    +      return c.json(
    +        {
    +          success: false,
    +          error: error.message,
    +          data: {
    +            code: error.errorDetails?.code,
    +            details: error.errorDetails?.details,
    +          },
    +        },
    +        400
    +      );
    +    }
    +
    +    if (error instanceof PluginTransportError) {
    +      const status = error.statusCode || 500;
    +      return c.json(
    +        {
    +          success: false,
    +          error: error.message,
    +        },
    +        status as 400 | 401 | 403 | 404 | 500 | 502 | 503
    +      );
    +    }
    +
    +    return c.json(
    +      {
    +        success: false,
    +        error: error instanceof Error ? error.message : "Relayer request failed",
    +      },
    +      500
    +    );
    +  }
    +});
    +
    +/**
    + * Get proxy status and client info.
    + * GET /status
    + */
    +app.get("/status", async (c) => {
    +  try {
    +    const ip = getClientIP(c.req.raw);
    +    const kvKey = getKVKey(ip);
    +
    +    const apiKey = await readStoredApiKey(c.env, kvKey);
    +
    +    return c.json({
    +      success: true,
    +      data: {
    +        clientIP: ip,
    +        network: c.env.NETWORK,
    +        hasKey: !!apiKey,
    +        keyCreatedAt: apiKey?.storedKey.createdAt,
    +      },
    +    });
    +  } catch (error) {
    +    console.error("Status endpoint failed:", error);
    +    return c.json(
    +      {
    +        success: false,
    +        error: "Could not read status",
    +      },
    +      500
    +    );
    +  }
    +});
    +
    +// ============================================================================
    +// Worker Export
    +// ============================================================================
    +
    +export default {
    +  fetch: app.fetch,
    +};
    diff --git a/relayer-proxy/tsconfig.json b/relayer-proxy/tsconfig.json
    new file mode 100644
    index 0000000..3b3e4cd
    --- /dev/null
    +++ b/relayer-proxy/tsconfig.json
    @@ -0,0 +1,13 @@
    +{
    +  "compilerOptions": {
    +    "target": "ES2022",
    +    "module": "ESNext",
    +    "lib": ["ES2022"],
    +    "moduleResolution": "Bundler",
    +    "types": ["@cloudflare/workers-types", "vitest/globals"],
    +    "strict": true,
    +    "noEmit": true,
    +    "skipLibCheck": true
    +  },
    +  "include": ["src/**/*", "worker-configuration.d.ts"]
    +}
    diff --git a/relayer-proxy/worker-configuration.d.ts b/relayer-proxy/worker-configuration.d.ts
    new file mode 100644
    index 0000000..dd6c253
    --- /dev/null
    +++ b/relayer-proxy/worker-configuration.d.ts
    @@ -0,0 +1,13 @@
    +/// 
    +
    +// Worker bindings. Regenerate the full Cloudflare runtime typings with
    +// `wrangler types` if you need them; this hand-written subset is all the worker
    +// code references.
    +interface Env {
    +  /** Per-IP Relayer API key cache. */
    +  API_KEYS: KVNamespace;
    +  /** Which network this instance serves. */
    +  NETWORK: "testnet" | "mainnet";
    +  /** OpenZeppelin Relayer Channels base URL for this network. */
    +  RELAYER_BASE_URL: string;
    +}
    diff --git a/relayer-proxy/wrangler.toml b/relayer-proxy/wrangler.toml
    new file mode 100644
    index 0000000..6ec5bfc
    --- /dev/null
    +++ b/relayer-proxy/wrangler.toml
    @@ -0,0 +1,40 @@
    +name = "passkey-kit-relayer-proxy"
    +main = "src/index.ts"
    +compatibility_date = "2024-12-01"
    +compatibility_flags = ["nodejs_compat"]
    +
    +# KV namespace for storing one Relayer API key per client IP.
    +# Provision before the first deploy and paste the returned id below:
    +#   wrangler kv namespace create API_KEYS
    +# (This repo ships a placeholder id — replace it; NO deploys happen from the
    +# services todo.)
    +[[kv_namespaces]]
    +binding = "API_KEYS"
    +id = "REPLACE_WITH_TESTNET_KV_NAMESPACE_ID"
    +# For `wrangler dev` you can add a preview namespace:
    +#   wrangler kv namespace create API_KEYS --preview
    +# preview_id = "REPLACE_WITH_PREVIEW_KV_NAMESPACE_ID"
    +
    +# Raise the CPU limit for testnet's Friendbot-funding retry loop (up to 5
    +# minutes). Network wait time (fetch) does not count toward CPU time. Mainnet
    +# never retries, so it uses the default limit.
    +[limits]
    +cpu_ms = 300_000
    +
    +[vars]
    +NETWORK = "testnet"
    +RELAYER_BASE_URL = "https://channels.openzeppelin.com/testnet"
    +
    +# ============================================================================
    +# Production environment (mainnet). Deploy: wrangler deploy --env production
    +# ============================================================================
    +[env.production]
    +name = "passkey-kit-relayer-proxy-mainnet"
    +
    +[env.production.vars]
    +NETWORK = "mainnet"
    +RELAYER_BASE_URL = "https://channels.openzeppelin.com"
    +
    +[[env.production.kv_namespaces]]
    +binding = "API_KEYS"
    +id = "REPLACE_WITH_MAINNET_KV_NAMESPACE_ID"
    diff --git a/wrangler.toml b/wrangler.toml
    new file mode 100644
    index 0000000..a8c0fd5
    --- /dev/null
    +++ b/wrangler.toml
    @@ -0,0 +1,24 @@
    +# Cloudflare Pages config for the demo (audit #597: "deploy broken-as-scripted —
    +# no wrangler.toml anywhere; deploy:demo has no project/output dir").
    +#
    +# `deploy:demo` in the root package.json runs `npx wrangler pages deploy` FROM
    +# THE REPO ROOT with no arguments, so the config it needs must live here. With
    +# `name` + `pages_build_output_dir` set, that script now resolves the project
    +# and the built assets directory without extra flags.
    +#
    +# Build the demo first (`pnpm run build:demo` -> demo/dist), then deploy.
    +name = "passkey-kit-demo"
    +pages_build_output_dir = "demo/dist"
    +compatibility_date = "2024-12-01"
    +
    +# `deploy:demo:prod` runs `wrangler pages deploy --env production`. NOTE:
    +# `wrangler pages deploy` has no first-class `--env`; Pages selects
    +# production-vs-preview by BRANCH (`--branch`), not environment. This section
    +# only exists so `--env production` resolves instead of erroring. The correct
    +# fix belongs in the root package.json (owned by the demo/root, not services):
    +# change `deploy:demo:prod` to `wrangler pages deploy --branch production` (or
    +# drop it — a plain `pages deploy` publishes to production). See the C2 services
    +# todo comment for the handoff to the demo agent.
    +[env.production]
    +name = "passkey-kit-demo"
    +pages_build_output_dir = "demo/dist"
    
    From ff06059603609e616ef989f95af190f7038626ad Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sat, 11 Jul 2026 22:41:01 -0400
    Subject: [PATCH 19/30] docs(contracts): re-pin FINAL post-audit-gate canonical
     hashes
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Audit gate cleared (all 7 findings confirmed-closed: terra FIX-1/4, sol
    FIX-2/3/5/6/7). Re-pins the manifest to the final post-remediation build
    (commit range 067f807..0563520).
    
    Final canonical hashes (supersede interim 9e7fad44…/e6d00383…):
    - smart-wallet:     84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319 (29,275 B)
    - sample-policy:    e74af5355f933f2c3421845178ca789c9bcf3ea7612a7c9966b9b57f26e59aed (13,064 B)
    - example-contract: 47a3326360bdce2ca360ed1b226ad636e14ac698f63efc11ab28e0d41f1f76aa (unchanged)
    
    Rebuilt clean from 0563520 (Rust 1.94.0 / soroban-sdk 27.0.0 / CLI 27.0.0),
    uploaded to testnet (smart-wallet tx ceb4ed26…, sample-policy tx 2f36077b…),
    all three fetched back by hash byte-identical, and live-smoked a fresh wallet
    instance (CCK7VZBY…, deploy tx 4a9ff09b…, get_signer verified). Legacy hash +
    normative derivation-tuple sections unchanged.
    
    Co-Authored-By: Claude Fable 5 
    ---
     docs/deployments-testnet-2026-07-11.md | 45 +++++++++++++++-----------
     1 file changed, 26 insertions(+), 19 deletions(-)
    
    diff --git a/docs/deployments-testnet-2026-07-11.md b/docs/deployments-testnet-2026-07-11.md
    index 5675561..515ba22 100644
    --- a/docs/deployments-testnet-2026-07-11.md
    +++ b/docs/deployments-testnet-2026-07-11.md
    @@ -5,22 +5,26 @@ set. The Makefile, SDK configuration, `verify:bindings` drift guard, and the
     zephyr (Mercury) ingestion allowlist must all consume the hashes recorded
     here — never a locally rebuilt hash.
     
    -> **Status: pre-audit-gate.** These artifacts were built after the A1/A2
    -> contract rework and test suite, but before the independent 3-way audit
    -> (Fable + gpt-5.6-sol + terra adversarial review). Any audit-driven contract
    -> change re-pins this manifest; the table below is superseded by a newer
    -> `docs/deployments-*.md` if one exists.
    +> **Status: post-audit-gate (FINAL for testnet).** These are the reworked v1
    +> contracts AFTER the independent 3-way audit (Fable + gpt-5.6-sol + terra)
    +> and all seven confirmed-closed remediations (FIX-1..7 + FIX-3b). The hashes
    +> below supersede the interim pre-audit values (smart-wallet
    +> `9e7fad44…`, sample-policy `e6d00383…`). The audit gate's final static
    +> `/code-review` pass runs after this re-pin; a further contract change would
    +> re-pin this manifest again.
     
     ## Build provenance
     
    -- Repo commit: `ff7bd227a30db8c0613a46ccebccc8d81f94ef04` (branch `overhaul/ground-up`)
    +- Repo commit: `0563520` (branch `overhaul/ground-up`); post-audit contract
    +  set spans commit range `067f807..0563520` (A1 rework → A2 tests → A4
    +  audit fixes → FIX-3b).
     - Rust/Cargo: `1.94.0` (pinned in `contracts/rust-toolchain.toml`)
     - `soroban-sdk`: `27.0.0` (`e5cb4b52c3da8e56fc48adfd7b85d85976c1a059`)
     - Stellar CLI: `27.0.0` (`5a7c5fe76530bf4248477ac812fc757146b98cc4`)
     - Build target: `wasm32v1-none`
     - Build command: `stellar contract build --locked --package  --out-dir out`
       (optimization is on by default in CLI 27; `out/.wasm` is the final artifact)
    -- Contract meta: `binver: 1.0.0`
    +- Contract meta: `binver: 1.0.0`, `rsver: 1.94.0`, `rssdkver: 27.0.0`
     
     Note: the wasm hash is sensitive to source line numbers (panic `Location`
     data is embedded), so only builds from the exact commit above reproduce these
    @@ -28,16 +32,19 @@ hashes. Reproducibility was verified by rebuilding from a clean `out/`.
     
     | Component | Cargo package | Bytes | SHA-256 / network WASM hash |
     |---|---|---:|---|
    -| Smart wallet | `smart-wallet` | 29,183 | `9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe` |
    -| Sample policy | `sample-policy` | 10,144 | `e6d0038301764191467ff245de4f95645cbd626d36f0598317f734ad73c164f6` |
    +| Smart wallet | `smart-wallet` | 29,275 | `84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319` |
    +| Sample policy | `sample-policy` | 13,064 | `e74af5355f933f2c3421845178ca789c9bcf3ea7612a7c9966b9b57f26e59aed` |
     | Example contract | `example-contract` | 1,047 | `47a3326360bdce2ca360ed1b226ad636e14ac698f63efc11ab28e0d41f1f76aa` |
     
     The smart wallet WASM is uploaded but never deployed as a singleton: every
     user wallet deploys its own instance with a `Signer` constructor argument.
     The sample policy demonstrates the v1 policy lifecycle
    -(`install`/`uninstall`/`policy__`). The example contract is a test fixture
    -(deploy-path and multi-transfer auth exercises); it is uploaded for
    -completeness but nothing depends on it.
    +(`install`/`uninstall`/`policy__`) as a cumulative rolling-window spending
    +allowance. The example contract is a test fixture (deploy-path and
    +multi-transfer auth exercises); it is uploaded for completeness but nothing
    +depends on it. The example-contract WASM is byte-identical to the pre-audit
    +build (its source was untouched by the audit fixes), so its hash and upload
    +are unchanged.
     
     ## Testnet
     
    @@ -47,17 +54,17 @@ friendbot. Network: Test SDF Network ; September 2015.
     
     | Component | Upload transaction |
     |---|---|
    -| Smart wallet | `f0ef240c4ce44d9271318abe367b3ebea9578966df0370d338b2d557d0efd916` |
    -| Sample policy | `2a1ebc27b98eef868d7415ce281f930d94ac535a8df4957e6146cf6e06a27fa4` |
    -| Example contract | `9d4da6b0992de465154ffea9dca3ea3e72948b0f706ec7e5a7f920dc4ddb2de7` |
    +| Smart wallet | `ceb4ed26734d4e16344ddc713fe19f1a1c8cd814fcddca56d685f64fb0ae70ba` |
    +| Sample policy | `2f36077bc6faf9bc8df1c58bfc1ee4c55079c762844e998da92ee4fd75ab094c` |
    +| Example contract | `9d4da6b0992de465154ffea9dca3ea3e72948b0f706ec7e5a7f920dc4ddb2de7` (unchanged since A3) |
     
     ### Live smoke
     
    -A wallet instance was deployed from the uploaded WASM with an Ed25519
    -constructor signer (tx
    -`5f4ef8114835f64c3b2e2125034190109ab29c6375435f12ceaedc8cb4808670`):
    +A wallet instance was deployed from the FINAL smart-wallet WASM with an
    +Ed25519 constructor signer (tx
    +`4a9ff09bfad96cf5398bbac973714690b5d4915c332292dc67c173fd332ad95a`):
     
    -- Instance: `CCHSQWC5BDRAPL4JGGKHUNHNY2QD2JZCDV6QNC34HIBACQAYKRB6IX4N`
    +- Instance: `CCK7VZBYMIIL7VBSEIORYQ4BTBZCKDKPNHPTYKQCRW4JO7L2IFW3X6WA`
     - `get_signer({"Ed25519": })` returned
       `{"Ed25519":[[null],[null]]}` — the constructor-stored signer, unlimited,
       no expiration.
    
    From 56282c2c1f93c3c0ebdb27cc7d7e0f5f8e297fa3 Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sat, 11 Jul 2026 22:42:37 -0400
    Subject: [PATCH 20/30] C1 services: re-pin zephyr allowlist to FINAL
     post-audit-gate wasm hash
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    The contracts audit gate + FIX-1..7/FIX-3b re-pinned the canonical smart-wallet
    WASM hash (docs/deployments-testnet-2026-07-11.md now FINAL post-audit):
    9e7fad44… (interim pre-audit) -> 84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319.
    Update the F3 ingestion allowlist + README to consume the manifest's final hash.
    Legacy hash e45c42b9… unchanged. Builds green.
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     zephyr/README.md     | 3 ++-
     zephyr/src/wallet.rs | 5 +++--
     2 files changed, 5 insertions(+), 3 deletions(-)
    
    diff --git a/zephyr/README.md b/zephyr/README.md
    index 23a0e7d..9ef6886 100644
    --- a/zephyr/README.md
    +++ b/zephyr/README.md
    @@ -115,7 +115,8 @@ Any contract can emit well-formed `signer_added` events; without a gate an
     attacker could inject rows and poison the reverse lookup. On each ledger close
     the program scans **successful** contract-instance ledger entries and records a
     wallet as trusted only when its instance executable hash is a known passkey-kit
    -WASM (`src/wallet.rs::ALLOWLISTED_WASM_HASHES`, sourced from
    +WASM (`src/wallet.rs::ALLOWLISTED_WASM_HASHES` — the FINAL post-audit-gate v1
    +hash `84924c53…` + legacy `e45c42b9…`, sourced from
     `docs/deployments-testnet-2026-07-11.md` — never rebuilt locally). Signer events
     are ingested only from wallets in the trusted set. The SDK still verifies
     ownership client-side (audit F7) as defence in depth.
    diff --git a/zephyr/src/wallet.rs b/zephyr/src/wallet.rs
    index f9d854c..6940adf 100644
    --- a/zephyr/src/wallet.rs
    +++ b/zephyr/src/wallet.rs
    @@ -115,8 +115,9 @@ pub struct UpgradedData {
     /// `signer_added`/… events could inject arbitrary signer rows and poison the
     /// reverse (keyId -> wallet) lookup (audit F3).
     pub const ALLOWLISTED_WASM_HASHES: [&str; 2] = [
    -    // v1 smart wallet — soroban-sdk 27, #[contractevent] schema.
    -    "9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe",
    +    // v1 smart wallet — soroban-sdk 27, #[contractevent] schema. FINAL
    +    // post-audit-gate hash (supersedes the interim pre-audit 9e7fad44…).
    +    "84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319",
         // Legacy pre-1.0 wallet — sw_v1 tuple events (ignored by this indexer), but
         // a known smart wallet: a legacy wallet can upgrade in place to a v1 hash
         // and keep its address, at which point it starts emitting v1 events.
    
    From a655795b79a5ce4846b971cdf0fd7122f901ae0b Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sat, 11 Jul 2026 22:48:19 -0400
    Subject: [PATCH 21/30] B4 SDK: re-run bindings regen against FINAL post-audit
     wasm 84924c53
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Re-pin canonical smart-wallet WASM
    9e7fad44… -> 84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319
    (A4 audit-gate remediation; manifest docs/deployments-testnet-2026-07-11.md,
    ff06059) in scripts/bindings/{build,verify}.sh.
    
    Regenerated passkey-kit-sdk bindings from the new WASM via the pinned
    build.sh (stellar contract fetch --wasm-hash -> bindings typescript, CLI
    27.0.0). The wallet interface is semantically UNCHANGED: every error code
    (100-125), error name, function signature, and type struct is byte-identical.
    The only spec delta is two embedded Rust doc-comment strings the audit
    reworded -- UserPresenceRequired (FIX-7) and SignerLimits (FIX-5) -- which
    live inside the ScSpec and so shift the base64 spec literals.
    
    - src/contract-errors.ts: no resync needed (codes/names identical);
      contract-errors.test.ts bindings-sync guard green.
    - build.sh/verify.sh: bindings output dir gen -> pks-gen so the generated
      README's package name stays stable across regens (name is spec-independent;
      verify compares only the base64 spec, so the guard is unaffected).
    - verify:bindings passes against 84924c53…; pnpm build + npx vitest run
      (132 tests, 17 files) green.
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     packages/passkey-kit-sdk/src/index.ts | 34 +++++++++++++++++----------
     scripts/bindings/build.sh             | 11 +++++----
     scripts/bindings/verify.sh            |  6 ++---
     3 files changed, 31 insertions(+), 20 deletions(-)
    
    diff --git a/packages/passkey-kit-sdk/src/index.ts b/packages/passkey-kit-sdk/src/index.ts
    index 6587daa..edaed05 100644
    --- a/packages/passkey-kit-sdk/src/index.ts
    +++ b/packages/passkey-kit-sdk/src/index.ts
    @@ -92,10 +92,16 @@ export const Errors = {
        */
       124: {message:"InvalidAuthenticatorData"},
       /**
    -   * The authenticator did not set the User Present (UP) flag. User
    -   * Verification (UV) is deliberately NOT required; requiring UP only
    -   * keeps silent, non-interactive assertions out while remaining
    -   * compatible with authenticators that don't do user verification.
    +   * The authenticator did not set the User Present (UP) flag.
    +   * 
    +   * UP-only is the deliberate default (audit FIX-7). Requiring UP keeps
    +   * silent, non-interactive assertions out while staying compatible with
    +   * authenticators that cannot do User Verification (UV — biometric/PIN).
    +   * UV is therefore NOT required by this contract. A deployment that wants
    +   * UV-required assertions should enforce it at the client/relayer layer,
    +   * or via a future per-signer flag (which would be a signer-model change,
    +   * not a change to this check); the contract cannot upgrade UP-only
    +   * signers to UV-required retroactively without such a flag.
        */
       125: {message:"UserPresenceRequired"}
     }
    @@ -144,14 +150,16 @@ export type Signatures = readonly [Map];
      * - `Some({address -> None})`: the signer may authorize any invocation of
      * contract `address`, with no co-signers required.
      * - `Some({address -> Some([keys])})`: the signer may authorize invocations
    - * of contract `address` only if every listed key also passes: non-policy
    - * keys must be present in the transaction's signatures map (and therefore
    - * fully verified in pass 2 of `__check_auth`); policy keys are invoked via
    - * `policy__` for the specific context (and, if the policy key is also
    - * stored on this wallet, its own stored limits are verified recursively,
    - * bounded by a fixed depth guard).
    + * of contract `address` only if every listed key also APPROVES. The listed
    + * keys are required CO-SIGNERS.
      * 
    - * Note
    + * ## Required co-signers are scope-independent approvers (audit FIX-5)
    + * 
    + * A required co-signer's OWN `SignerLimits` do NOT constrain its co-signer
    + * role — a key's limits govern only its INDEPENDENT authority (whether it can
    + * cover a context on its own). This is symmetric across key kinds:
    + * 
    + * - 
      */
     export type SignerLimits = readonly [Option>>>];
     
    @@ -241,13 +249,13 @@ export class Client extends ContractClient {
             "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAEAAAAAAAAABnNpZ25lcgAAAAAH0AAAAAZTaWduZXIAAAAAAAA=",
             "AAAAAAAAAAAAAAANcmVtb3ZlX3NpZ25lcgAAAAAAAAEAAAAAAAAACnNpZ25lcl9rZXkAAAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAQAAA+kAAAACAAAAAw==",
             "AAAAAAAAAAAAAAANdXBkYXRlX3NpZ25lcgAAAAAAAAEAAAAAAAAABnNpZ25lcgAAAAAH0AAAAAZTaWduZXIAAAAAAAEAAAPpAAAAAgAAAAM=",
    -        "AAAABAAAAVBDb250cmFjdCBlcnJvcnMuCgpEZWxpYmVyYXRlbHkgcmVudW1iZXJlZCBmb3IgdGhlIHYxIGludGVyZmFjZSBzbyB0aGUgZXJyb3Igc3BhY2UgaXMgZGlzam9pbnQKZnJvbSB0aGUgbGVnYWN5IChwcmUtMS4wKSBjb250cmFjdCdzIDEtOSByYW5nZS4gQSBjbGllbnQgZGVjb2RpbmcgYW4gZXJyb3IKY29kZSA8IDEwMCBpcyB0YWxraW5nIHRvIGEgbGVnYWN5IHdhbGxldC4KClJhbmdlczoKLSAxMDAtMTA5OiBzaWduZXIgc3RvcmFnZSAvIG1hbmFnZW1lbnQKLSAxMTAtMTE5OiBhdXRoIChgX19jaGVja19hdXRoYCkKLSAxMjAtMTI5OiBXZWJBdXRobiAoc2VjcDI1NnIxKSB2ZXJpZmljYXRpb24AAAAAAAAABUVycm9yAAAAAAAACwAAADlUaGUgcmVxdWVzdGVkIHNpZ25lciBkb2VzIG5vdCBleGlzdCBvbiB0aGlzIHNtYXJ0IHdhbGxldC4AAAAAAAAOU2lnbmVyTm90Rm91bmQAAAAAAGQAAAA+YGFkZF9zaWduZXJgIHdhcyBjYWxsZWQgd2l0aCBhIHNpZ25lciBrZXkgdGhhdCBhbHJlYWR5IGV4aXN0cy4AAAAAABNTaWduZXJBbHJlYWR5RXhpc3RzAAAAAGUAAAAxVGhlIHNpZ25lcidzIGV4cGlyYXRpb24gdGltZXN0YW1wIGlzIGluIHRoZSBwYXN0LgAAAAAAAA1TaWduZXJFeHBpcmVkAAAAAAAAZgAAAF1ObyBzaWduZXIgaW4gdGhlIHNpZ25hdHVyZXMgbWFwIGlzIHBlcm1pdHRlZCB0byBhdXRob3JpemUgb25lIG9mIHRoZQpyZXF1ZXN0ZWQgYXV0aCBjb250ZXh0cy4AAAAAAAAOTWlzc2luZ0NvbnRleHQAAAAAAG4AAACJQSBzaWduYXR1cmUncyB2YXJpYW50IGRvZXMgbm90IG1hdGNoIHRoZSBzdG9yZWQgc2lnbmVyIGl0IGNsYWltcyB0byBiZQpmb3IgKGUuZy4gYW4gRWQyNTUxOSBzaWduYXR1cmUgc3VibWl0dGVkIGZvciBhIFBvbGljeSBzaWduZXIga2V5KS4AAAAAAAAZU2lnbmF0dXJlS2V5VmFsdWVNaXNtYXRjaAAAAAAAAG8AAAAyY2xpZW50RGF0YUpTT04gZXhjZWVkcyB0aGUgMTAyNCBieXRlIHBhcnNlIGJ1ZmZlci4AAAAAABZDbGllbnREYXRhSnNvblRvb0xhcmdlAAAAAAB4AAAARWNsaWVudERhdGFKU09OIGlzIG5vdCBwYXJzZWFibGUgSlNPTiAob3IgaXMgbWlzc2luZyByZXF1aXJlZCBmaWVsZHMpLgAAAAAAABhDbGllbnREYXRhSnNvblBhcnNlRXJyb3IAAAB5AAAAtlRoZSBjaGFsbGVuZ2UgaW4gY2xpZW50RGF0YUpTT04gZG9lcyBub3QgbWF0Y2ggdGhlIGJhc2U2NHVybC1lbmNvZGVkCnNpZ25hdHVyZSBwYXlsb2FkLiBUaGlzIGJpbmRzIHRoZSBXZWJBdXRobiBhc3NlcnRpb24gdG8gdGhlIFNvcm9iYW4KYXV0aG9yaXphdGlvbiBlbnRyeSBhbmQgTVVTVCBOT1QgYmUgd2Vha2VuZWQuAAAAAAAgQ2xpZW50RGF0YUpzb25DaGFsbGVuZ2VJbmNvcnJlY3QAAAB6AAAALGNsaWVudERhdGFKU09OIGB0eXBlYCBpcyBub3QgIndlYmF1dGhuLmdldCIuAAAAE0ludmFsaWRXZWJBdXRoblR5cGUAAAAAewAAAGlhdXRoZW50aWNhdG9yRGF0YSBpcyBzaG9ydGVyIHRoYW4gdGhlIFdlYkF1dGhuIG1pbmltdW0gb2YgMzcgYnl0ZXMKKHJwSWRIYXNoIDMyICsgZmxhZ3MgMSArIHNpZ25Db3VudCA0KS4AAAAAAAAYSW52YWxpZEF1dGhlbnRpY2F0b3JEYXRhAAAAfAAAAP1UaGUgYXV0aGVudGljYXRvciBkaWQgbm90IHNldCB0aGUgVXNlciBQcmVzZW50IChVUCkgZmxhZy4gVXNlcgpWZXJpZmljYXRpb24gKFVWKSBpcyBkZWxpYmVyYXRlbHkgTk9UIHJlcXVpcmVkOyByZXF1aXJpbmcgVVAgb25seQprZWVwcyBzaWxlbnQsIG5vbi1pbnRlcmFjdGl2ZSBhc3NlcnRpb25zIG91dCB3aGlsZSByZW1haW5pbmcKY29tcGF0aWJsZSB3aXRoIGF1dGhlbnRpY2F0b3JzIHRoYXQgZG9uJ3QgZG8gdXNlciB2ZXJpZmljYXRpb24uAAAAAAAAFFVzZXJQcmVzZW5jZVJlcXVpcmVkAAAAfQ==",
    +        "AAAABAAAAVBDb250cmFjdCBlcnJvcnMuCgpEZWxpYmVyYXRlbHkgcmVudW1iZXJlZCBmb3IgdGhlIHYxIGludGVyZmFjZSBzbyB0aGUgZXJyb3Igc3BhY2UgaXMgZGlzam9pbnQKZnJvbSB0aGUgbGVnYWN5IChwcmUtMS4wKSBjb250cmFjdCdzIDEtOSByYW5nZS4gQSBjbGllbnQgZGVjb2RpbmcgYW4gZXJyb3IKY29kZSA8IDEwMCBpcyB0YWxraW5nIHRvIGEgbGVnYWN5IHdhbGxldC4KClJhbmdlczoKLSAxMDAtMTA5OiBzaWduZXIgc3RvcmFnZSAvIG1hbmFnZW1lbnQKLSAxMTAtMTE5OiBhdXRoIChgX19jaGVja19hdXRoYCkKLSAxMjAtMTI5OiBXZWJBdXRobiAoc2VjcDI1NnIxKSB2ZXJpZmljYXRpb24AAAAAAAAABUVycm9yAAAAAAAACwAAADlUaGUgcmVxdWVzdGVkIHNpZ25lciBkb2VzIG5vdCBleGlzdCBvbiB0aGlzIHNtYXJ0IHdhbGxldC4AAAAAAAAOU2lnbmVyTm90Rm91bmQAAAAAAGQAAAA+YGFkZF9zaWduZXJgIHdhcyBjYWxsZWQgd2l0aCBhIHNpZ25lciBrZXkgdGhhdCBhbHJlYWR5IGV4aXN0cy4AAAAAABNTaWduZXJBbHJlYWR5RXhpc3RzAAAAAGUAAAAxVGhlIHNpZ25lcidzIGV4cGlyYXRpb24gdGltZXN0YW1wIGlzIGluIHRoZSBwYXN0LgAAAAAAAA1TaWduZXJFeHBpcmVkAAAAAAAAZgAAAF1ObyBzaWduZXIgaW4gdGhlIHNpZ25hdHVyZXMgbWFwIGlzIHBlcm1pdHRlZCB0byBhdXRob3JpemUgb25lIG9mIHRoZQpyZXF1ZXN0ZWQgYXV0aCBjb250ZXh0cy4AAAAAAAAOTWlzc2luZ0NvbnRleHQAAAAAAG4AAACJQSBzaWduYXR1cmUncyB2YXJpYW50IGRvZXMgbm90IG1hdGNoIHRoZSBzdG9yZWQgc2lnbmVyIGl0IGNsYWltcyB0byBiZQpmb3IgKGUuZy4gYW4gRWQyNTUxOSBzaWduYXR1cmUgc3VibWl0dGVkIGZvciBhIFBvbGljeSBzaWduZXIga2V5KS4AAAAAAAAZU2lnbmF0dXJlS2V5VmFsdWVNaXNtYXRjaAAAAAAAAG8AAAAyY2xpZW50RGF0YUpTT04gZXhjZWVkcyB0aGUgMTAyNCBieXRlIHBhcnNlIGJ1ZmZlci4AAAAAABZDbGllbnREYXRhSnNvblRvb0xhcmdlAAAAAAB4AAAARWNsaWVudERhdGFKU09OIGlzIG5vdCBwYXJzZWFibGUgSlNPTiAob3IgaXMgbWlzc2luZyByZXF1aXJlZCBmaWVsZHMpLgAAAAAAABhDbGllbnREYXRhSnNvblBhcnNlRXJyb3IAAAB5AAAAtlRoZSBjaGFsbGVuZ2UgaW4gY2xpZW50RGF0YUpTT04gZG9lcyBub3QgbWF0Y2ggdGhlIGJhc2U2NHVybC1lbmNvZGVkCnNpZ25hdHVyZSBwYXlsb2FkLiBUaGlzIGJpbmRzIHRoZSBXZWJBdXRobiBhc3NlcnRpb24gdG8gdGhlIFNvcm9iYW4KYXV0aG9yaXphdGlvbiBlbnRyeSBhbmQgTVVTVCBOT1QgYmUgd2Vha2VuZWQuAAAAAAAgQ2xpZW50RGF0YUpzb25DaGFsbGVuZ2VJbmNvcnJlY3QAAAB6AAAALGNsaWVudERhdGFKU09OIGB0eXBlYCBpcyBub3QgIndlYmF1dGhuLmdldCIuAAAAE0ludmFsaWRXZWJBdXRoblR5cGUAAAAAewAAAGlhdXRoZW50aWNhdG9yRGF0YSBpcyBzaG9ydGVyIHRoYW4gdGhlIFdlYkF1dGhuIG1pbmltdW0gb2YgMzcgYnl0ZXMKKHJwSWRIYXNoIDMyICsgZmxhZ3MgMSArIHNpZ25Db3VudCA0KS4AAAAAAAAYSW52YWxpZEF1dGhlbnRpY2F0b3JEYXRhAAAAfAAAAlpUaGUgYXV0aGVudGljYXRvciBkaWQgbm90IHNldCB0aGUgVXNlciBQcmVzZW50IChVUCkgZmxhZy4KClVQLW9ubHkgaXMgdGhlIGRlbGliZXJhdGUgZGVmYXVsdCAoYXVkaXQgRklYLTcpLiBSZXF1aXJpbmcgVVAga2VlcHMKc2lsZW50LCBub24taW50ZXJhY3RpdmUgYXNzZXJ0aW9ucyBvdXQgd2hpbGUgc3RheWluZyBjb21wYXRpYmxlIHdpdGgKYXV0aGVudGljYXRvcnMgdGhhdCBjYW5ub3QgZG8gVXNlciBWZXJpZmljYXRpb24gKFVWIOKAlCBiaW9tZXRyaWMvUElOKS4KVVYgaXMgdGhlcmVmb3JlIE5PVCByZXF1aXJlZCBieSB0aGlzIGNvbnRyYWN0LiBBIGRlcGxveW1lbnQgdGhhdCB3YW50cwpVVi1yZXF1aXJlZCBhc3NlcnRpb25zIHNob3VsZCBlbmZvcmNlIGl0IGF0IHRoZSBjbGllbnQvcmVsYXllciBsYXllciwKb3IgdmlhIGEgZnV0dXJlIHBlci1zaWduZXIgZmxhZyAod2hpY2ggd291bGQgYmUgYSBzaWduZXItbW9kZWwgY2hhbmdlLApub3QgYSBjaGFuZ2UgdG8gdGhpcyBjaGVjayk7IHRoZSBjb250cmFjdCBjYW5ub3QgdXBncmFkZSBVUC1vbmx5CnNpZ25lcnMgdG8gVVYtcmVxdWlyZWQgcmV0cm9hY3RpdmVseSB3aXRob3V0IHN1Y2ggYSBmbGFnLgAAAAAAFFVzZXJQcmVzZW5jZVJlcXVpcmVkAAAAfQ==",
             "AAAAAgAAAFJGdWxsIHNpZ25lciBkZXNjcmlwdGlvbiB1c2VkIGJ5IGBfX2NvbnN0cnVjdG9yYCwgYGFkZF9zaWduZXJgIGFuZApgdXBkYXRlX3NpZ25lcmAuAAAAAAAAAAAABlNpZ25lcgAAAAAAAwAAAAEAAAAAAAAABlBvbGljeQAAAAAABAAAABMAAAfQAAAAEFNpZ25lckV4cGlyYXRpb24AAAfQAAAADFNpZ25lckxpbWl0cwAAB9AAAAANU2lnbmVyU3RvcmFnZQAAAAAAAAEAAAAAAAAAB0VkMjU1MTkAAAAABAAAA+4AAAAgAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAABAAAAAAAAAAlTZWNwMjU2cjEAAAAAAAAFAAAADgAAA+4AAABBAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAA=",
             "AAAAAgAAAJ1BIHNpZ25hdHVyZSBlbnRyeSBpbiB0aGUgc2lnbmF0dXJlcyBtYXAuIGBQb2xpY3lgIGNhcnJpZXMgbm8gc2lnbmF0dXJlCm1hdGVyaWFsOiBpbmNsdXNpb24gb2YgdGhlIHBvbGljeSBrZXkgYXV0aG9yaXplcyBhbiBvbi1jaGFpbiBgcG9saWN5X19gCmNoZWNrIGluc3RlYWQuAAAAAAAAAAAAAAlTaWduYXR1cmUAAAAAAAADAAAAAAAAAAAAAAAGUG9saWN5AAAAAAABAAAAAAAAAAdFZDI1NTE5AAAAAAEAAAPuAAAAQAAAAAEAAAAAAAAACVNlY3AyNTZyMQAAAAAAAAEAAAfQAAAAElNlY3AyNTZyMVNpZ25hdHVyZQAA",
             "AAAAAgAAAFlTdG9yYWdlIGtleSBpZGVudGlmeWluZyBhIHNpZ25lci4gU2VjcDI1NnIxIGNhcnJpZXMgdGhlIFdlYkF1dGhuCmNyZWRlbnRpYWwgaWQgKGBrZXlJZGApLgAAAAAAAAAAAAAJU2lnbmVyS2V5AAAAAAAAAwAAAAEAAAAAAAAABlBvbGljeQAAAAAAAQAAABMAAAABAAAAAAAAAAdFZDI1NTE5AAAAAAEAAAPuAAAAIAAAAAEAAAAAAAAACVNlY3AyNTZyMQAAAAAAAAEAAAAO",
             "AAAAAgAAAElTdG9yZWQgc2lnbmVyIHZhbHVlLiBTZWNwMjU2cjEgY2FycmllcyB0aGUgU0VDLTEgdW5jb21wcmVzc2VkIHB1YmxpYyBrZXkuAAAAAAAAAAAAAAlTaWduZXJWYWwAAAAAAAADAAAAAQAAAAAAAAAGUG9saWN5AAAAAAACAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAABAAAAAAAAAAdFZDI1NTE5AAAAAAIAAAfQAAAAEFNpZ25lckV4cGlyYXRpb24AAAfQAAAADFNpZ25lckxpbWl0cwAAAAEAAAAAAAAACVNlY3AyNTZyMQAAAAAAAAMAAAPuAAAAQQAAB9AAAAAQU2lnbmVyRXhwaXJhdGlvbgAAB9AAAAAMU2lnbmVyTGltaXRz",
             "AAAAAQAAANNUaGUgYF9fY2hlY2tfYXV0aGAgc2lnbmF0dXJlIG9iamVjdDogYSBtYXAgb2Ygc2lnbmVyIGtleXMgdG8gc2lnbmF0dXJlcy4KTWFwIG9yZGVyaW5nIGlzIHRoZSBob3N0J3MgU2NWYWwgb3JkZXJpbmcuIEVWRVJZIGVudHJ5IG11c3QgdmVyaWZ5IChwYXNzCjIgb2YgYF9fY2hlY2tfYXV0aGApIOKAlCBpbmNsdWRlIG9ubHkgc2lnbmF0dXJlcyB0aGF0IGFyZSBuZWVkZWQuAAAAAAAAAAAKU2lnbmF0dXJlcwAAAAAAAQAAAAAAAAABMAAAAAAAA+wAAAfQAAAACVNpZ25lcktleQAAAAAAB9AAAAAJU2lnbmF0dXJlAAAA",
    -        "AAAAAQAABABSZXN0cmljdGlvbnMgb24gd2hpY2ggYXV0aCBjb250ZXh0cyBhIHNpZ25lciBtYXkgYXV0aG9yaXplLgoKLSBgTm9uZWA6IHVubGltaXRlZC4gVGhlIHNpZ25lciBjYW4gYXV0aG9yaXplIGFueXRoaW5nLCBpbmNsdWRpbmcKYENyZWF0ZUNvbnRyYWN0KmAgKGRlcGxveSkgY29udGV4dHMgYW5kIHRoaXMgd2FsbGV0J3Mgb3duIGFkbWluCmZ1bmN0aW9ucy4KLSBgU29tZShlbXB0eSBtYXApYDogTk8gcGVybWlzc2lvbnMgKGZhaWwtY2xvc2VkKS4gVGhlIHNpZ25lciBjYW4gYXV0aG9yaXplCm5vdGhpbmcgZXhjZXB0IHJlbW92aW5nIGl0c2VsZiAoc2VlIGJlbG93KS4gdjEgYnJlYWtpbmcgY2hhbmdlOiBwcmUtMS4wCmFuIGVtcHR5IG1hcCBtZWFudCB1bmxpbWl0ZWQsIGxlYXZpbmcgdHdvIHVubGltaXRlZCBlbmNvZGluZ3MgYW5kIG5vCiJub25lIiBlbmNvZGluZy4KLSBgU29tZSh7YWRkcmVzcyAtPiBOb25lfSlgOiB0aGUgc2lnbmVyIG1heSBhdXRob3JpemUgYW55IGludm9jYXRpb24gb2YKY29udHJhY3QgYGFkZHJlc3NgLCB3aXRoIG5vIGNvLXNpZ25lcnMgcmVxdWlyZWQuCi0gYFNvbWUoe2FkZHJlc3MgLT4gU29tZShba2V5c10pfSlgOiB0aGUgc2lnbmVyIG1heSBhdXRob3JpemUgaW52b2NhdGlvbnMKb2YgY29udHJhY3QgYGFkZHJlc3NgIG9ubHkgaWYgZXZlcnkgbGlzdGVkIGtleSBhbHNvIHBhc3Nlczogbm9uLXBvbGljeQprZXlzIG11c3QgYmUgcHJlc2VudCBpbiB0aGUgdHJhbnNhY3Rpb24ncyBzaWduYXR1cmVzIG1hcCAoYW5kIHRoZXJlZm9yZQpmdWxseSB2ZXJpZmllZCBpbiBwYXNzIDIgb2YgYF9fY2hlY2tfYXV0aGApOyBwb2xpY3kga2V5cyBhcmUgaW52b2tlZCB2aWEKYHBvbGljeV9fYCBmb3IgdGhlIHNwZWNpZmljIGNvbnRleHQgKGFuZCwgaWYgdGhlIHBvbGljeSBrZXkgaXMgYWxzbwpzdG9yZWQgb24gdGhpcyB3YWxsZXQsIGl0cyBvd24gc3RvcmVkIGxpbWl0cyBhcmUgdmVyaWZpZWQgcmVjdXJzaXZlbHksCmJvdW5kZWQgYnkgYSBmaXhlZCBkZXB0aCBndWFyZCkuCgpOb3RlAAAAAAAAAAxTaWduZXJMaW1pdHMAAAABAAAAAAAAAAEwAAAAAAAD6AAAA+wAAAATAAAD6AAAA+oAAAfQAAAACVNpZ25lcktleQAAAA==",
    +        "AAAAAQAABABSZXN0cmljdGlvbnMgb24gd2hpY2ggYXV0aCBjb250ZXh0cyBhIHNpZ25lciBtYXkgYXV0aG9yaXplLgoKLSBgTm9uZWA6IHVubGltaXRlZC4gVGhlIHNpZ25lciBjYW4gYXV0aG9yaXplIGFueXRoaW5nLCBpbmNsdWRpbmcKYENyZWF0ZUNvbnRyYWN0KmAgKGRlcGxveSkgY29udGV4dHMgYW5kIHRoaXMgd2FsbGV0J3Mgb3duIGFkbWluCmZ1bmN0aW9ucy4KLSBgU29tZShlbXB0eSBtYXApYDogTk8gcGVybWlzc2lvbnMgKGZhaWwtY2xvc2VkKS4gVGhlIHNpZ25lciBjYW4gYXV0aG9yaXplCm5vdGhpbmcgZXhjZXB0IHJlbW92aW5nIGl0c2VsZiAoc2VlIGJlbG93KS4gdjEgYnJlYWtpbmcgY2hhbmdlOiBwcmUtMS4wCmFuIGVtcHR5IG1hcCBtZWFudCB1bmxpbWl0ZWQsIGxlYXZpbmcgdHdvIHVubGltaXRlZCBlbmNvZGluZ3MgYW5kIG5vCiJub25lIiBlbmNvZGluZy4KLSBgU29tZSh7YWRkcmVzcyAtPiBOb25lfSlgOiB0aGUgc2lnbmVyIG1heSBhdXRob3JpemUgYW55IGludm9jYXRpb24gb2YKY29udHJhY3QgYGFkZHJlc3NgLCB3aXRoIG5vIGNvLXNpZ25lcnMgcmVxdWlyZWQuCi0gYFNvbWUoe2FkZHJlc3MgLT4gU29tZShba2V5c10pfSlgOiB0aGUgc2lnbmVyIG1heSBhdXRob3JpemUgaW52b2NhdGlvbnMKb2YgY29udHJhY3QgYGFkZHJlc3NgIG9ubHkgaWYgZXZlcnkgbGlzdGVkIGtleSBhbHNvIEFQUFJPVkVTLiBUaGUgbGlzdGVkCmtleXMgYXJlIHJlcXVpcmVkIENPLVNJR05FUlMuCgojIyBSZXF1aXJlZCBjby1zaWduZXJzIGFyZSBzY29wZS1pbmRlcGVuZGVudCBhcHByb3ZlcnMgKGF1ZGl0IEZJWC01KQoKQSByZXF1aXJlZCBjby1zaWduZXIncyBPV04gYFNpZ25lckxpbWl0c2AgZG8gTk9UIGNvbnN0cmFpbiBpdHMgY28tc2lnbmVyCnJvbGUg4oCUIGEga2V5J3MgbGltaXRzIGdvdmVybiBvbmx5IGl0cyBJTkRFUEVOREVOVCBhdXRob3JpdHkgKHdoZXRoZXIgaXQgY2FuCmNvdmVyIGEgY29udGV4dCBvbiBpdHMgb3duKS4gVGhpcyBpcyBzeW1tZXRyaWMgYWNyb3NzIGtleSBraW5kczoKCi0gAAAAAAAAAAxTaWduZXJMaW1pdHMAAAABAAAAAAAAAAEwAAAAAAAD6AAAA+wAAAATAAAD6AAAA+oAAAfQAAAACVNpZ25lcktleQAAAA==",
             "AAAAAgAAAIRXaGljaCBkdXJhYmlsaXR5IGEgc2lnbmVyIGVudHJ5IGlzIHN0b3JlZCB1bmRlci4gQXQgbW9zdCBvbmUgZW50cnkgZXhpc3RzCnBlciBzaWduZXIga2V5OyBsb29rdXBzIGNoZWNrIFRlbXBvcmFyeSBiZWZvcmUgUGVyc2lzdGVudC4AAAAAAAAADVNpZ25lclN0b3JhZ2UAAAAAAAACAAAAAAAAAAAAAAAKUGVyc2lzdGVudAAAAAAAAAAAAAAAAAAJVGVtcG9yYXJ5AAAA",
             "AAAAAQAAAY5PcHRpb25hbCBleHBpcmF0aW9uIGZvciBhIHNpZ25lciBhcyBhIFVOSVggdGltZXN0YW1wIGluIHNlY29uZHMsIElOQ0xVU0lWRToKdGhlIHNpZ25lciBpcyB2YWxpZCB3aGlsZSBgbGVkZ2VyIHRpbWVzdGFtcCA8PSBleHBpcmF0aW9uYCBhbmQgZXhwaXJlZCBvbmNlCmBsZWRnZXIgdGltZXN0YW1wID4gZXhwaXJhdGlvbmAuIGBOb25lYCBuZXZlciBleHBpcmVzLgoKdjEgYnJlYWtpbmcgY2hhbmdlOiB0aGlzIHdhcyBhIGxlZGdlciBzZXF1ZW5jZSBudW1iZXIgcHJlLTEuMC4gVGltZXN0YW1wcwpkb24ndCBkcmlmdCB3aXRoIGNoYW5nZXMgdG8gbGVkZ2VyIGNsb3NlIHRpbWUgKGUuZy4gQ0FQLTAwNzAgZHluYW1pYwp0aW1pbmcpLCB3aGljaCBsZWRnZXItc2VxdWVuY2UgZXhwaXJhdGlvbnMgZGlkLgAAAAAAAAAAABBTaWduZXJFeHBpcmF0aW9uAAAAAQAAAAAAAAABMAAAAAAAA+gAAAAG",
             "AAAAAQAAAMhBIFdlYkF1dGhuIGFzc2VydGlvbiBvdmVyIHRoZSBTb3JvYmFuIGF1dGhvcml6YXRpb24gcGF5bG9hZC4gVGhlIHNpZ25lZAptZXNzYWdlIGlzIGBhdXRoZW50aWNhdG9yX2RhdGEgfHwgc2hhMjU2KGNsaWVudF9kYXRhX2pzb24pYCBhbmQgdGhlCnBheWxvYWQgYmluZGluZyBsaXZlcyBpbiBjbGllbnREYXRhSlNPTidzIGBjaGFsbGVuZ2VgIGZpZWxkLgAAAAAAAAASU2VjcDI1NnIxU2lnbmF0dXJlAAAAAAADAAAAAAAAABJhdXRoZW50aWNhdG9yX2RhdGEAAAAAAA4AAAAAAAAAEGNsaWVudF9kYXRhX2pzb24AAAAOAAAAAAAAAAlzaWduYXR1cmUAAAAAAAPuAAAAQA==",
    diff --git a/scripts/bindings/build.sh b/scripts/bindings/build.sh
    index 04fbb55..ea1df9c 100755
    --- a/scripts/bindings/build.sh
    +++ b/scripts/bindings/build.sh
    @@ -13,7 +13,7 @@ set -euo pipefail
     
     # Canonical smart-wallet WASM hash — keep in sync with the deployments manifest
     # (docs/deployments-testnet-2026-07-11.md). Pinned Stellar CLI: 27.0.0.
    -CANONICAL_HASH="9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe"
    +CANONICAL_HASH="84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319"
     NETWORK="testnet"
     
     ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
    @@ -34,12 +34,15 @@ if [ "$ACTUAL" != "$CANONICAL_HASH" ]; then
     fi
     
     echo "Generating TypeScript bindings…"
    +# The CLI derives the generated README's package name from the output-dir
    +# basename; keep it "pks-gen" so the released README name stays stable across
    +# regens (verify.sh compares only the name-independent spec base64).
     stellar contract bindings typescript --wasm "$TMP/smart-wallet.wasm" \
    -  --overwrite --output-dir "$TMP/gen" >/dev/null
    +  --overwrite --output-dir "$TMP/pks-gen" >/dev/null
     
     echo "Applying post-gen patches (copy generated spec, preserve packaging)…"
    -cp "$TMP/gen/src/index.ts" "$PKG_SRC"
    -cp "$TMP/gen/README.md" "$PKG_README"
    +cp "$TMP/pks-gen/src/index.ts" "$PKG_SRC"
    +cp "$TMP/pks-gen/README.md" "$PKG_README"
     
     echo "✓ Regenerated passkey-kit-sdk from canonical WASM ${CANONICAL_HASH}"
     echo "  Run 'pnpm --filter passkey-kit-sdk run build' to rebuild dist."
    diff --git a/scripts/bindings/verify.sh b/scripts/bindings/verify.sh
    index 010381b..1095183 100755
    --- a/scripts/bindings/verify.sh
    +++ b/scripts/bindings/verify.sh
    @@ -10,7 +10,7 @@
     # Usage: bash scripts/bindings/verify.sh
     set -euo pipefail
     
    -CANONICAL_HASH="9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe"
    +CANONICAL_HASH="84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319"
     NETWORK="testnet"
     
     ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
    @@ -31,7 +31,7 @@ if [ "$ACTUAL" != "$CANONICAL_HASH" ]; then
     fi
     
     stellar contract bindings typescript --wasm "$TMP/smart-wallet.wasm" \
    -  --overwrite --output-dir "$TMP/gen" >/dev/null
    +  --overwrite --output-dir "$TMP/pks-gen" >/dev/null
     
     # Extract the ContractSpec base64 string literals (>= 16 chars, base64 charset),
     # in order. This is the wasm-determined spec, independent of CLI TS formatting.
    @@ -41,7 +41,7 @@ extract_spec() {
     
     if diff -u \
       <(extract_spec "$PKG_SRC") \
    -  <(extract_spec "$TMP/gen/src/index.ts"); then
    +  <(extract_spec "$TMP/pks-gen/src/index.ts"); then
       echo "✓ Committed bindings match canonical WASM ${CANONICAL_HASH}"
     else
       echo "" >&2
    
    From ebcd8cd7d5206e17cee7adb2a9a67157fb84d0a1 Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sat, 11 Jul 2026 22:50:31 -0400
    Subject: [PATCH 22/30] chore: re-pin demo VITE_walletWasmHash to final
     post-audit hash
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Fixes stale-hash drift in demo/.env.example (was pinned to the pre-audit
    9e7fad44… despite the re-pin comment). Now matches the canonical
    smart-wallet hash 84924c53… from docs/deployments-testnet-2026-07-11.md.
    
    Co-Authored-By: Claude Fable 5 
    ---
     demo/.env.example | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    
    diff --git a/demo/.env.example b/demo/.env.example
    index 6819792..b07607f 100644
    --- a/demo/.env.example
    +++ b/demo/.env.example
    @@ -11,7 +11,7 @@ VITE_networkPassphrase="Test SDF Network ; September 2015"
     
     # Smart-wallet WASM hash — pinned from docs/deployments-testnet-2026-07-11.md
     # (canonical source). Re-pinned after the contract audit gate + bindings regen.
    -VITE_walletWasmHash="9e7fad441d6560b31eafbf3b627dbc196cf19df4dcdb91e0aededaf6590d6fbe"
    +VITE_walletWasmHash="84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319"
     
     # --- Tokens (SAC / SEP-41) ---------------------------------------------------
     # Native XLM SAC on testnet.
    
    From fb54e40be662ed5f7ac1b0fd69ea74dfeab720c9 Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sat, 11 Jul 2026 23:11:04 -0400
    Subject: [PATCH 23/30] fix(demo): align relayer-proxy client to worker
     envelope + unblock auto-fund
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Code-review gate (todo 966, comment 2496) findings in demo/** scope.
    
    H1 [HIGH] — demo submission happy-path was fully broken: the relayer-proxy worker
    returns { success, data: { transactionId, hash, status } } but the client read
    payload.hash/ledger/transactionId at the TOP level, so payload.hash was always
    undefined → the success guard fired on EVERY 200 → every submission (deploy /
    transfer / add-update-remove-signer / fund) reported failure. Fix: unwrap the
    `data` envelope (payload?.data), treat an explicit success:false as failure, and
    drop `ledger` (the worker has none). Error path: the worker returns `error` as a
    STRING + optional data:{code,details}; toError now reads the string message +
    data.code (numeric → contractErrorFromCode) and scans message/raw for
    Error(Contract,#N) so contract-error codes are no longer dropped.
    
    M3 [MED] — register() called fundWallet(100) nested inside run("Create wallet");
    fundWallet wraps its body in run(), which early-returns when app.busy is set, so
    the post-deploy auto-fund silently no-oped. Factored an unwrapped fundWalletInner()
    (fundWallet is now a thin run() wrapper) and register calls it directly, guarded so
    a fund failure never fails wallet creation.
    
    LOW — fundWalletInner uses BigInt(Math.round(amountXlm * 10_000_000)) so a
    fractional fund input ( allows 0.5) no longer throws RangeError.
    format.ts toStroops/fromStroops now use exact decimal↔BigInt math (no JS Number
    rounding / precision loss); number inputs in scientific notation fall back safely.
    
    pnpm build:demo green (svelte-check 0/0 + vite build). Worker unchanged (client
    aligned to worker shape per the finding). Refs todo 966 / comment 2496.
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     demo/src/lib/actions.ts       | 49 +++++++++++++++++---------
     demo/src/lib/format.ts        | 35 +++++++++++++++----
     demo/src/lib/relayer-proxy.ts | 66 +++++++++++++++++++++--------------
     3 files changed, 101 insertions(+), 49 deletions(-)
    
    diff --git a/demo/src/lib/actions.ts b/demo/src/lib/actions.ts
    index c12a38f..ee9c38d 100644
    --- a/demo/src/lib/actions.ts
    +++ b/demo/src/lib/actions.ts
    @@ -161,7 +161,15 @@ export async function register(name: string): Promise {
         await refreshKnown();
     
         await refreshBalance();
    -    await fundWallet(100);
    +    // Auto-fund the fresh wallet. Call the UNWRAPPED helper: `app.busy` is
    +    // already set by this `run("Create wallet")` block, so `fundWallet` (which
    +    // wraps in its own `run`) would hit the busy-guard and silently no-op. A
    +    // fund failure must not fail wallet creation (the wallet is already live).
    +    try {
    +      await fundWalletInner(100);
    +    } catch (err) {
    +      pushLog("error", `Auto-fund failed: ${describeError(err).message}`);
    +    }
       });
     }
     
    @@ -449,22 +457,31 @@ function signersFor(signWith: SignWith): Signer[] {
     
     export async function fundWallet(amountXlm: number): Promise {
       if (!requireConnected()) return;
    -  await run(`Fund wallet (${amountXlm} XLM)`, async () => {
    -    const fundKp = await fundKeypair;
    -    const signer = await fundSigner();
    -    const native = sac.getSACClient(config.nativeContractId);
    -    const at = await native.transfer({
    -      to: app.contractId!,
    -      from: fundKp.publicKey(),
    -      amount: BigInt(amountXlm) * 10_000_000n,
    -    });
    -    await at.signAuthEntries({
    -      address: fundKp.publicKey(),
    -      signAuthEntry: signer.signAuthEntry,
    -    });
    -    if (!reportResult(`Fund wallet`, await submit(at))) return;
    -    await refreshBalance();
    +  await run(`Fund wallet (${amountXlm} XLM)`, () => fundWalletInner(amountXlm));
    +}
    +
    +/**
    + * The fund body WITHOUT a `run()` wrapper, so it can be called from inside
    + * another flow's `run()` block (e.g. `register`) without tripping the busy-guard
    + * (which early-returns when `app.busy` is already set).
    + */
    +async function fundWalletInner(amountXlm: number): Promise {
    +  const fundKp = await fundKeypair;
    +  const signer = await fundSigner();
    +  const native = sac.getSACClient(config.nativeContractId);
    +  const at = await native.transfer({
    +    to: app.contractId!,
    +    from: fundKp.publicKey(),
    +    // `` allows fractional XLM; BigInt() throws on a
    +    // non-integer, so scale then round to whole stroops.
    +    amount: BigInt(Math.round(amountXlm * 10_000_000)),
       });
    +  await at.signAuthEntries({
    +    address: fundKp.publicKey(),
    +    signAuthEntry: signer.signAuthEntry,
    +  });
    +  if (!reportResult(`Fund wallet`, await submit(at))) return;
    +  await refreshBalance();
     }
     
     export async function refreshBalance(): Promise {
    diff --git a/demo/src/lib/format.ts b/demo/src/lib/format.ts
    index 137cada..c9060bc 100644
    --- a/demo/src/lib/format.ts
    +++ b/demo/src/lib/format.ts
    @@ -10,18 +10,39 @@ export function shortId(id: string | undefined, edge = 6): string {
       return id.length <= edge * 2 + 1 ? id : `${id.slice(0, edge)}…${id.slice(-edge)}`;
     }
     
    -/** Format i128 stroops as a human XLM/token amount (7 decimals). */
    +const STROOPS_PER_UNIT = 10_000_000n;
    +
    +/**
    + * Format i128 stroops as a human XLM/token amount (7 decimals), using exact
    + * BigInt math (no JS `Number`, which loses integer precision above ~9e15).
    + */
     export function fromStroops(stroops: string | bigint | undefined): string {
       if (stroops === undefined) return "—";
    -  const value = Number(BigInt(stroops)) / 10_000_000;
    -  return parseFloat(value.toFixed(7)).toString();
    +  const value = BigInt(stroops);
    +  const negative = value < 0n;
    +  const abs = negative ? -value : value;
    +  const whole = abs / STROOPS_PER_UNIT;
    +  const frac = (abs % STROOPS_PER_UNIT).toString().padStart(7, "0").replace(/0+$/, "");
    +  return `${negative ? "-" : ""}${whole}${frac ? `.${frac}` : ""}`;
     }
     
    -/** Parse a human token amount into i128 stroops. */
    +/**
    + * Parse a human token amount into i128 stroops. String inputs are parsed with
    + * exact decimal→BigInt math (no `Number` rounding); numbers fall back to a safe
    + * scale-and-round (demo-scale amounts only).
    + */
     export function toStroops(amount: string | number): bigint {
    -  const n = typeof amount === "string" ? Number(amount) : amount;
    -  if (!Number.isFinite(n) || n < 0) throw new Error("Invalid amount");
    -  return BigInt(Math.round(n * 10_000_000));
    +  const str = typeof amount === "string" ? amount.trim() : String(amount);
    +  const match = /^(\d+)(?:\.(\d+))?$/.exec(str);
    +  if (!match) {
    +    // Scientific notation etc. from a number input: scale then round.
    +    const n = Number(amount);
    +    if (!Number.isFinite(n) || n < 0) throw new Error("Invalid amount");
    +    return BigInt(Math.round(n * 10_000_000));
    +  }
    +  const whole = BigInt(match[1]);
    +  const frac = BigInt((match[2] ?? "").padEnd(7, "0").slice(0, 7));
    +  return whole * STROOPS_PER_UNIT + frac;
     }
     
     /** A structured, human-readable description of any thrown value. */
    diff --git a/demo/src/lib/relayer-proxy.ts b/demo/src/lib/relayer-proxy.ts
    index 5c9a928..71ceedd 100644
    --- a/demo/src/lib/relayer-proxy.ts
    +++ b/demo/src/lib/relayer-proxy.ts
    @@ -22,12 +22,23 @@ export type RelayerProxyBody =
       | { func: string; auth: string[] }
       | { xdr: string };
     
    -/** Shape the worker returns on a non-2xx response. */
    -interface WorkerError {
    -  message?: string;
    -  code?: string;
    -  contractCode?: number;
    -  details?: unknown;
    +/**
    + * The worker's response envelope (relayer-proxy/src/index.ts):
    + *   success → `{ success: true, data: { transactionId, hash, status } }`
    + *   failure → `{ success: false, error: , data?: { code, details } }`
    + *             with a non-2xx HTTP status.
    + * There is no `ledger` field.
    + */
    +interface WorkerEnvelope {
    +  success?: boolean;
    +  error?: string;
    +  data?: {
    +    transactionId?: string;
    +    hash?: string;
    +    status?: string;
    +    code?: string | number;
    +    details?: unknown;
    +  };
     }
     
     const TIMEOUT_MS = 6 * 60 * 1000;
    @@ -67,17 +78,16 @@ export class RelayerProxyClient {
           });
     
           const text = await res.text();
    -      const payload = text ? safeParse(text) : undefined;
    +      const payload = text ? (safeParse(text) as WorkerEnvelope | undefined) : undefined;
     
    -      if (!res.ok) {
    +      // Failure = non-2xx OR an explicit `success: false` (defensive).
    +      if (!res.ok || payload?.success === false) {
             return fail(toError(payload, res.status, text));
           }
     
    -      const ok = (payload ?? {}) as {
    -        hash?: string;
    -        ledger?: number;
    -        transactionId?: string;
    -      };
    +      // Success: unwrap the `data` envelope. The worker returns no `ledger`, so
    +      // it stays undefined; `transactionId`/`status` come from `data`.
    +      const ok = payload?.data ?? {};
           if (!ok.hash) {
             return fail(
               new RelayerError(
    @@ -89,7 +99,6 @@ export class RelayerProxyClient {
           return {
             success: true,
             hash: ok.hash,
    -        ledger: ok.ledger,
             transactionId: ok.transactionId,
           };
         } catch (err) {
    @@ -122,27 +131,32 @@ function safeParse(text: string): unknown {
       }
     }
     
    -/** Map a worker error payload to a typed SDK error (ContractError when decodable). */
    +/** Map the worker error envelope to a typed SDK error (ContractError when decodable). */
     function toError(
    -  payload: unknown,
    +  payload: WorkerEnvelope | undefined,
       status: number,
       raw: string,
     ): PasskeyKitError {
    -  const e =
    -    (payload as { error?: WorkerError })?.error ?? (payload as WorkerError);
       const message =
    -    e?.message ?? `Relayer proxy failed (HTTP ${status}): ${raw.slice(0, 200)}`;
    -
    -  // Prefer an explicit contract code, else scan the raw body for Error(Contract, #N).
    +    (typeof payload?.error === "string" && payload.error) ||
    +    `Relayer proxy failed (HTTP ${status}): ${raw.slice(0, 200)}`;
    +  const code = payload?.data?.code;
    +  const details = payload?.data?.details;
    +
    +  // A numeric `data.code` may be a contract error code; otherwise scan the
    +  // message + raw body for an `Error(Contract, #N)` marker (the relayer surfaces
    +  // the contract failure there) so contract-error codes aren't dropped.
       const contract =
    -    (typeof e?.contractCode === "number"
    -      ? contractErrorFromCode(e.contractCode, { status, details: e.details })
    -      : null) ?? decodeContractError(raw);
    +    (typeof code === "number"
    +      ? contractErrorFromCode(code, { status, details })
    +      : null) ??
    +    decodeContractError(message) ??
    +    decodeContractError(raw);
       if (contract) return contract;
     
       return new RelayerError(message, PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, {
         status,
    -    code: e?.code,
    -    details: e?.details,
    +    code: code === undefined ? undefined : String(code),
    +    details,
       });
     }
    
    From b6c6a60929518ab6de54fe47af7db7abcc60b642 Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sat, 11 Jul 2026 23:22:05 -0400
    Subject: [PATCH 24/30] services: address code-review gate findings (M4, M5,
     LOWs)
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    zephyr (M4 + LOW):
    - M4: fold same-ledger signer events per (address,key) into one pending state,
      then flush with a single existence-read + single write. Host db writes commit
      only after on_close, so per-event read+write couldn't see this close's own
      writes — add+remove left a signer active forever, two updates duplicated rows.
      Now correct (mirrors the wallets `trusted` in-memory cache).
    - LOW: read handlers parse caller-supplied address/key fallibly (validate strkey
      / match base64 Result) and conclude empty instead of panicking (panic=abort).
    
    relayer-proxy (M5 + LOWs):
    - M5: move per-IP key custody from KV (no CAS → N concurrent first-requests mint
      N keys) into a per-IP Durable Object (`ApiKeyStore`); blockConcurrencyWhile
      serializes get-or-create so at most ONE /gen key is minted per IP. Drops the
      KV binding; wrangler.toml gains the DO binding + migration (both envs).
    - LOW: never log /gen body or candidate key material — log only status/length.
    - LOW: getClientIP uses CF-Connecting-IP ONLY (unspoofable at the edge); dropped
      the client-controlled X-Forwarded-For/X-Real-IP fallback. CORS left open with
      an explicit endgame/mainnet decision note.
    - Tests rewritten for the DO model (21 pass): worker DO integration + direct
      ApiKeyStore get-or-create/peek/mint-fail. tsc clean.
    
    package.json (LOW): deploy:demo:prod uses `--branch production` (Pages selects
    prod by branch, not `--env`).
    
    Not fixed here (other owners): H1 (demo client envelope unwrap), H2/M1/M2 (SDK),
    M3 + demo LOWs (demo). No Mercury redeploy (F2).
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     package.json                            |   2 +-
     relayer-proxy/README.md                 |  32 +--
     relayer-proxy/src/constants.ts          |   8 +-
     relayer-proxy/src/index.test.ts         | 240 +++++++++++----------
     relayer-proxy/src/index.ts              | 265 +++++++++---------------
     relayer-proxy/worker-configuration.d.ts |   4 +-
     relayer-proxy/wrangler.toml             |  32 +--
     zephyr/src/lib.rs                       | 258 +++++++++++++++--------
     8 files changed, 430 insertions(+), 411 deletions(-)
    
    diff --git a/package.json b/package.json
    index 3d5ebe8..8f326d1 100644
    --- a/package.json
    +++ b/package.json
    @@ -40,7 +40,7 @@
         "clean": "rm -rf dist packages/passkey-kit-sdk/dist packages/sac-sdk/dist",
         "build:demo": "cd demo && pnpm --ignore-workspace install && pnpm run build",
         "deploy:demo": "npx wrangler pages deploy",
    -    "deploy:demo:prod": "npx wrangler pages deploy --env production",
    +    "deploy:demo:prod": "npx wrangler pages deploy --branch production",
         "prepublishOnly": "pnpm run verify:bindings && pnpm run build"
       },
       "dependencies": {
    diff --git a/relayer-proxy/README.md b/relayer-proxy/README.md
    index e078b97..84a3e15 100644
    --- a/relayer-proxy/README.md
    +++ b/relayer-proxy/README.md
    @@ -11,10 +11,15 @@ demo's client JS (audit #597/#598).
     ## How it works
     
     - **Keyless per-IP key minting.** On the first request from an IP the worker
    -  calls the Relayer's unauthenticated `GET {RELAYER_BASE_URL}/gen` endpoint,
    -  and caches the returned key in KV under `api-key:` (indefinitely — the
    -  Relayer resets usage limits every 24h on its side). Client IP is taken from
    -  `CF-Connecting-IP`, then `X-Forwarded-For`, then `X-Real-IP`.
    +  calls the Relayer's unauthenticated `GET {RELAYER_BASE_URL}/gen` endpoint and
    +  stores the returned key. Custody lives in a **per-IP Durable Object**
    +  (`ApiKeyStore`, one instance per `CF-Connecting-IP`): its `blockConcurrencyWhile`
    +  serializes get-or-create, so N concurrent first-requests from one IP mint at
    +  most ONE key — the atomic get-or-create that KV cannot provide (no CAS). The
    +  key persists indefinitely; the Relayer resets usage limits every 24h.
    +- **Client IP** is taken ONLY from `CF-Connecting-IP` (always set at the CF
    +  edge, unspoofable). `X-Forwarded-For` / `X-Real-IP` are client-controlled and
    +  deliberately not trusted for key custody.
     - **Two submission modes** (`POST /`):
       - `{ func, auth }` → `submitSorobanTransaction` — Relayer builds the tx with
         channel accounts (Address credentials: transfers, wallet ops).
    @@ -42,13 +47,9 @@ demo's client JS (audit #597/#598).
     
     - Testnet (default): `NETWORK=testnet`, `RELAYER_BASE_URL=https://channels.openzeppelin.com/testnet`.
     - Mainnet (`[env.production]`): `NETWORK=mainnet`, `RELAYER_BASE_URL=https://channels.openzeppelin.com`.
    -- KV binding `API_KEYS` (one namespace per network). The committed ids are
    -  placeholders — provision real ones before deploying:
    -
    -```sh
    -wrangler kv namespace create API_KEYS            # testnet id -> [[kv_namespaces]]
    -wrangler kv namespace create API_KEYS            # mainnet id -> [[env.production.kv_namespaces]]
    -```
    +- Durable Object binding `API_KEY_DO` → class `ApiKeyStore` (+ a `[[migrations]]`
    +  entry), one per environment. No namespace id to provision — the class is bound
    +  directly, and the DO is created on first use.
     
     No secrets are needed; the worker mints Relayer keys itself.
     
    @@ -62,10 +63,11 @@ pnpm typecheck                     # tsc --noEmit
     pnpm dev                           # wrangler dev (local)
     ```
     
    -The tests mock `@openzeppelin/relayer-plugin-channels` and KV, and cover: IP
    -extraction, mode validation, both submission paths, the three error mappings,
    -testnet-vs-mainnet retry behavior, and the per-IP key lifecycle (mint / reuse /
    -legacy-format migration).
    +The tests mock `@openzeppelin/relayer-plugin-channels` and the DO namespace, and
    +cover: IP extraction (CF-Connecting-IP only), mode validation, both submission
    +paths, the three error mappings, testnet-vs-mainnet retry behavior, and the
    +`ApiKeyStore` DO get-or-create logic (returns stored / mints from `/gen` /
    +502-on-mint-failure / `/peek`).
     
     ## Deploy
     
    diff --git a/relayer-proxy/src/constants.ts b/relayer-proxy/src/constants.ts
    index 1f70d4b..9f7a6c9 100644
    --- a/relayer-proxy/src/constants.ts
    +++ b/relayer-proxy/src/constants.ts
    @@ -20,9 +20,6 @@ export const FRIENDBOT_URL = "https://friendbot.stellar.org";
     // API Key Configuration
     // ============================================================================
     
    -/** KV storage key prefix for API keys */
    -export const API_KEY_PREFIX = "api-key:";
    -
     /** Possible field names for the API key in the /gen response */
     export const API_KEY_FIELD_NAMES = ["apiKey", "api_key", "key", "token"] as const;
     
    @@ -53,10 +50,11 @@ export const TESTNET_RETRY_DURATION_MS = 5 * 60 * 1000;
     // HTTP Headers
     // ============================================================================
     
    +// Only CF-Connecting-IP is trusted: Cloudflare always sets it at the edge and
    +// the client cannot spoof it. X-Forwarded-For / X-Real-IP are client-controlled
    +// and deliberately NOT used for per-IP key custody (review LOW).
     export const IP_HEADERS = {
       CF_CONNECTING_IP: "CF-Connecting-IP",
    -  X_FORWARDED_FOR: "X-Forwarded-For",
    -  X_REAL_IP: "X-Real-IP",
     } as const;
     
     /** Default IP value when none can be determined */
    diff --git a/relayer-proxy/src/index.test.ts b/relayer-proxy/src/index.test.ts
    index f24d1ce..4c21375 100644
    --- a/relayer-proxy/src/index.test.ts
    +++ b/relayer-proxy/src/index.test.ts
    @@ -3,7 +3,7 @@ import {
       PluginExecutionError,
       PluginTransportError,
     } from "@openzeppelin/relayer-plugin-channels";
    -import worker, { extractMissingAccount, getClientIP } from "./index";
    +import worker, { ApiKeyStore, extractMissingAccount, getClientIP } from "./index";
     import { SERVICE_NAME } from "./constants";
     
     // ---------------------------------------------------------------------------
    @@ -46,30 +46,42 @@ vi.mock("@openzeppelin/relayer-plugin-channels", () => {
     // ---------------------------------------------------------------------------
     // Test doubles
     // ---------------------------------------------------------------------------
    -const SEEDED_KEY = "sk_seededapikey123456";
    +const DO_KEY = "sk_do_relay_key_1234567";
     const CLIENT_IP = "203.0.113.7";
    -const KV_KEY = `api-key:${CLIENT_IP}`;
     
    -function createKV(initial: Record = {}) {
    -  const store = new Map(Object.entries(initial));
    -  return {
    -    store,
    -    get: vi.fn(async (k: string) => (store.has(k) ? store.get(k)! : null)),
    -    put: vi.fn(async (k: string, v: string) => {
    -      store.set(k, v);
    -    }),
    -    delete: vi.fn(async (k: string) => {
    -      store.delete(k);
    +// Fake per-IP DurableObjectNamespace. The stub's fetch answers /get (returns a
    +// key, or 502 when `key` is null) and /peek (presence). This exercises the
    +// worker's DO integration; the ApiKeyStore's own get-or-create logic is tested
    +// directly below.
    +function makeApiKeyDO(opts: { key?: string | null } = {}) {
    +  const key = opts.key === undefined ? DO_KEY : opts.key;
    +  const stub = {
    +    fetch: vi.fn(async (input: unknown) => {
    +      const url =
    +        typeof input === "string" ? input : (input as Request).url ?? "";
    +      if (url.includes("/peek")) {
    +        return new Response(JSON.stringify({ hasKey: key !== null }), {
    +          status: 200,
    +          headers: { "Content-Type": "application/json" },
    +        });
    +      }
    +      if (key === null) return new Response("mint failed", { status: 502 });
    +      return new Response(key, { status: 200 });
         }),
       };
    +  return {
    +    idFromName: vi.fn((_ip: string) => "do-id"),
    +    get: vi.fn((_id: unknown) => stub),
    +    _stub: stub,
    +  };
     }
     
    -type FakeKV = ReturnType;
    -
    -function makeEnv(opts: { kv?: FakeKV; network?: "testnet" | "mainnet" } = {}) {
    +function makeEnv(
    +  opts: { network?: "testnet" | "mainnet"; do?: ReturnType } = {}
    +) {
       const network = opts.network ?? "testnet";
       return {
    -    API_KEYS: opts.kv ?? createKV(),
    +    API_KEY_DO: opts.do ?? makeApiKeyDO(),
         NETWORK: network,
         RELAYER_BASE_URL:
           network === "mainnet"
    @@ -78,12 +90,6 @@ function makeEnv(opts: { kv?: FakeKV; network?: "testnet" | "mainnet" } = {}) {
       } as any;
     }
     
    -function seededKV() {
    -  return createKV({
    -    [KV_KEY]: JSON.stringify({ apiKey: SEEDED_KEY, createdAt: 1000 }),
    -  });
    -}
    -
     const ctx = () =>
       ({ waitUntil: vi.fn(), passThroughOnException: vi.fn() }) as any;
     
    @@ -113,10 +119,8 @@ function notOk(status = 500, text = "error") {
       return { ok: false, status, text: async () => text };
     }
     
    -function stubFetch(handlers: {
    -  gen?: () => unknown;
    -  friendbot?: () => unknown;
    -}) {
    +// Stub the GLOBAL fetch (used by generateApiKey's /gen call and friendbot).
    +function stubFetch(handlers: { gen?: () => unknown; friendbot?: () => unknown }) {
       const fetchMock = vi.fn(async (input: unknown) => {
         const url = typeof input === "string" ? input : (input as Request).url;
         if (url.includes("/gen")) return handlers.gen?.() ?? okJson({});
    @@ -127,6 +131,22 @@ function stubFetch(handlers: {
       return fetchMock;
     }
     
    +// Fake DurableObjectState for direct ApiKeyStore tests.
    +function fakeState(initial?: string) {
    +  const store = new Map();
    +  if (initial !== undefined) store.set("apiKey", initial);
    +  return {
    +    storage: {
    +      get: vi.fn(async (k: string) => store.get(k)),
    +      put: vi.fn(async (k: string, v: unknown) => {
    +        store.set(k, v);
    +      }),
    +    },
    +    blockConcurrencyWhile: vi.fn(async (fn: () => Promise) => fn()),
    +    _store: store,
    +  } as any;
    +}
    +
     const G_ADDRESS = `G${"A".repeat(55)}`;
     
     afterEach(() => {
    @@ -138,27 +158,27 @@ afterEach(() => {
     // Pure helpers
     // ---------------------------------------------------------------------------
     describe("getClientIP", () => {
    -  it("prefers CF-Connecting-IP, then X-Forwarded-For, then X-Real-IP", () => {
    +  it("uses CF-Connecting-IP", () => {
         expect(
           getClientIP(
             new Request("http://x", { headers: { "CF-Connecting-IP": "1.1.1.1" } })
           )
         ).toBe("1.1.1.1");
    +  });
    +
    +  it("ignores spoofable X-Forwarded-For / X-Real-IP, falling back to 'unknown'", () => {
         expect(
           getClientIP(
             new Request("http://x", {
               headers: { "X-Forwarded-For": "2.2.2.2, 3.3.3.3" },
             })
           )
    -    ).toBe("2.2.2.2");
    +    ).toBe("unknown");
         expect(
           getClientIP(
             new Request("http://x", { headers: { "X-Real-IP": "4.4.4.4" } })
           )
    -    ).toBe("4.4.4.4");
    -  });
    -
    -  it("falls back to 'unknown' with no IP headers", () => {
    +    ).toBe("unknown");
         expect(getClientIP(new Request("http://x"))).toBe("unknown");
       });
     });
    @@ -197,7 +217,7 @@ describe("POST / validation", () => {
       it("rejects a request with neither xdr nor func+auth (400)", async () => {
         const res = await worker.fetch(
           makeRequest("/", { method: "POST", body: {} }),
    -      makeEnv({ kv: seededKV() }),
    +      makeEnv(),
           ctx()
         );
         expect(res.status).toBe(400);
    @@ -215,7 +235,7 @@ describe("POST / validation", () => {
             method: "POST",
             body: { xdr: "AAA", func: "BBB", auth: ["CCC"] },
           }),
    -      makeEnv({ kv: seededKV() }),
    +      makeEnv(),
           ctx()
         );
         expect(res.status).toBe(400);
    @@ -228,7 +248,7 @@ describe("POST / validation", () => {
       it("rejects malformed JSON (400)", async () => {
         const res = await worker.fetch(
           makeRequest("/", { method: "POST", body: "{not json" }),
    -      makeEnv({ kv: seededKV() }),
    +      makeEnv(),
           ctx()
         );
         expect(res.status).toBe(400);
    @@ -244,7 +264,6 @@ describe("POST / validation", () => {
     // ---------------------------------------------------------------------------
     describe("POST / submission", () => {
       it("submits a Soroban transaction (func+auth) and returns the result", async () => {
    -    stubFetch({});
         submitSorobanTransaction.mockResolvedValueOnce({
           transactionId: "tx1",
           hash: "h1",
    @@ -256,7 +275,7 @@ describe("POST / submission", () => {
             method: "POST",
             body: { func: "FUNC", auth: ["A1", "A2"] },
           }),
    -      makeEnv({ kv: seededKV() }),
    +      makeEnv(),
           ctx()
         );
     
    @@ -281,7 +300,7 @@ describe("POST / submission", () => {
     
         const res = await worker.fetch(
           makeRequest("/", { method: "POST", body: { xdr: "XDR" } }),
    -      makeEnv({ kv: seededKV() }),
    +      makeEnv(),
           ctx()
         );
     
    @@ -301,7 +320,7 @@ describe("POST / submission", () => {
     
         const res = await worker.fetch(
           makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    -      makeEnv({ kv: seededKV() }),
    +      makeEnv(),
           ctx()
         );
     
    @@ -320,7 +339,7 @@ describe("POST / submission", () => {
     
         const res = await worker.fetch(
           makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    -      makeEnv({ kv: seededKV() }),
    +      makeEnv(),
           ctx()
         );
     
    @@ -336,7 +355,7 @@ describe("POST / submission", () => {
     
         const res = await worker.fetch(
           makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    -      makeEnv({ kv: seededKV() }),
    +      makeEnv(),
           ctx()
         );
     
    @@ -346,6 +365,21 @@ describe("POST / submission", () => {
           error: "boom",
         });
       });
    +
    +  it("returns 500 when the API key cannot be obtained", async () => {
    +    const res = await worker.fetch(
    +      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    +      makeEnv({ do: makeApiKeyDO({ key: null }) }),
    +      ctx()
    +    );
    +
    +    expect(res.status).toBe(500);
    +    await expect(res.json()).resolves.toEqual({
    +      success: false,
    +      error: "Could not obtain API key. Service may be misconfigured.",
    +    });
    +    expect(submitSorobanTransaction).not.toHaveBeenCalled();
    +  });
     });
     
     // ---------------------------------------------------------------------------
    @@ -360,7 +394,7 @@ describe("POST / testnet retry", () => {
     
         const res = await worker.fetch(
           makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    -      makeEnv({ kv: seededKV(), network: "testnet" }),
    +      makeEnv({ network: "testnet" }),
           ctx()
         );
     
    @@ -385,7 +419,7 @@ describe("POST / testnet retry", () => {
     
         const res = await worker.fetch(
           makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    -      makeEnv({ kv: seededKV(), network: "mainnet" }),
    +      makeEnv({ network: "mainnet" }),
           ctx()
         );
     
    @@ -398,94 +432,59 @@ describe("POST / testnet retry", () => {
     });
     
     // ---------------------------------------------------------------------------
    -// Per-IP API key lifecycle
    +// ApiKeyStore Durable Object — serialized get-or-create
     // ---------------------------------------------------------------------------
    -describe("API key lifecycle", () => {
    -  it("mints a key from the Relayer /gen endpoint when none exists", async () => {
    -    const kv = createKV();
    -    const fetchMock = stubFetch({
    -      gen: () => okJson({ apiKey: "sk_generatedkey1234" }),
    -    });
    -    submitSorobanTransaction.mockResolvedValueOnce({
    -      transactionId: "tx",
    -      hash: "h",
    -      status: "ok",
    -    });
    -
    -    const res = await worker.fetch(
    -      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    -      makeEnv({ kv }),
    -      ctx()
    -    );
    +describe("ApiKeyStore (Durable Object)", () => {
    +  it("returns the stored key without minting", async () => {
    +    const state = fakeState("sk_stored_key_1234567");
    +    const fetchMock = stubFetch({ gen: () => okJson({ apiKey: "sk_unused" }) });
    +    const store = new ApiKeyStore(state, makeEnv());
     
    +    const res = await store.fetch(new Request("https://do/get"));
         expect(res.status).toBe(200);
    +    expect((await res.text()).trim()).toBe("sk_stored_key_1234567");
         expect(fetchMock.mock.calls.some(([u]) => String(u).includes("/gen"))).toBe(
    -      true
    +      false
         );
    -    const stored = JSON.parse(kv.store.get(KV_KEY)!);
    -    expect(stored.apiKey).toBe("sk_generatedkey1234");
    -    expect(typeof stored.createdAt).toBe("number");
       });
     
    -  it("returns 500 when a key cannot be minted", async () => {
    -    stubFetch({ gen: () => notOk(500, "denied") });
    -
    -    const res = await worker.fetch(
    -      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    -      makeEnv({ kv: createKV() }),
    -      ctx()
    -    );
    +  it("mints from /gen and stores when absent", async () => {
    +    const state = fakeState();
    +    stubFetch({ gen: () => okJson({ apiKey: "sk_minted_key_1234567" }) });
    +    const store = new ApiKeyStore(state, makeEnv());
     
    -    expect(res.status).toBe(500);
    -    await expect(res.json()).resolves.toEqual({
    -      success: false,
    -      error: "Could not obtain API key. Service may be misconfigured.",
    -    });
    -    expect(submitSorobanTransaction).not.toHaveBeenCalled();
    +    const res = await store.fetch(new Request("https://do/get"));
    +    expect(res.status).toBe(200);
    +    expect((await res.text()).trim()).toBe("sk_minted_key_1234567");
    +    expect(state._store.get("apiKey")).toBe("sk_minted_key_1234567");
       });
     
    -  it("migrates a legacy plain-text KV value to the JSON record", async () => {
    -    const legacyKey = "sk_plaintextkey12345";
    -    const kv = createKV({ [KV_KEY]: legacyKey });
    -    stubFetch({});
    -    submitSorobanTransaction.mockResolvedValueOnce({
    -      transactionId: "tx",
    -      hash: "h",
    -      status: "ok",
    -    });
    -
    -    const res = await worker.fetch(
    -      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    -      makeEnv({ kv }),
    -      ctx()
    -    );
    +  it("returns 502 when minting fails", async () => {
    +    const state = fakeState();
    +    stubFetch({ gen: () => notOk(500, "denied") });
    +    const store = new ApiKeyStore(state, makeEnv());
     
    -    expect(res.status).toBe(200);
    -    // Reused the legacy key (no /gen call) and rewrote it as a JSON record.
    -    const stored = JSON.parse(kv.store.get(KV_KEY)!);
    -    expect(stored.apiKey).toBe(legacyKey);
    -    expect(typeof stored.createdAt).toBe("number");
    +    const res = await store.fetch(new Request("https://do/get"));
    +    expect(res.status).toBe(502);
    +    expect(state._store.get("apiKey")).toBeUndefined();
       });
     
    -  it("migrates a legacy JSON-string KV value to the JSON record", async () => {
    -    const legacyKey = "sk_jsonstringkey1234";
    -    const kv = createKV({ [KV_KEY]: JSON.stringify(legacyKey) });
    -    stubFetch({});
    -    submitSorobanTransaction.mockResolvedValueOnce({
    -      transactionId: "tx",
    -      hash: "h",
    -      status: "ok",
    -    });
    -
    -    const res = await worker.fetch(
    -      makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }),
    -      makeEnv({ kv }),
    -      ctx()
    +  it("/peek reports presence without minting", async () => {
    +    const present = new ApiKeyStore(
    +      fakeState("sk_present_key_123456"),
    +      makeEnv()
         );
    +    const fetchMock = stubFetch({ gen: () => okJson({ apiKey: "sk_unused" }) });
    +    const res = await present.fetch(new Request("https://do/peek"));
    +    await expect(res.json()).resolves.toEqual({ hasKey: true });
     
    -    expect(res.status).toBe(200);
    -    const stored = JSON.parse(kv.store.get(KV_KEY)!);
    -    expect(stored.apiKey).toBe(legacyKey);
    +    const absent = new ApiKeyStore(fakeState(), makeEnv());
    +    const res2 = await absent.fetch(new Request("https://do/peek"));
    +    await expect(res2.json()).resolves.toEqual({ hasKey: false });
    +
    +    expect(fetchMock.mock.calls.some(([u]) => String(u).includes("/gen"))).toBe(
    +      false
    +    );
       });
     });
     
    @@ -494,11 +493,7 @@ describe("API key lifecycle", () => {
     // ---------------------------------------------------------------------------
     describe("GET /status", () => {
       it("returns client IP, network, and key presence", async () => {
    -    const res = await worker.fetch(
    -      makeRequest("/status"),
    -      makeEnv({ kv: seededKV() }),
    -      ctx()
    -    );
    +    const res = await worker.fetch(makeRequest("/status"), makeEnv(), ctx());
         expect(res.status).toBe(200);
         await expect(res.json()).resolves.toEqual({
           success: true,
    @@ -506,7 +501,6 @@ describe("GET /status", () => {
             clientIP: CLIENT_IP,
             network: "testnet",
             hasKey: true,
    -        keyCreatedAt: 1000,
           },
         });
       });
    diff --git a/relayer-proxy/src/index.ts b/relayer-proxy/src/index.ts
    index cd20174..c947c9a 100644
    --- a/relayer-proxy/src/index.ts
    +++ b/relayer-proxy/src/index.ts
    @@ -9,7 +9,8 @@
      * Uses the official `@openzeppelin/relayer-plugin-channels` SDK.
      *
      * Features:
    - * - Automatic API-key generation per IP (persisted indefinitely in KV).
    + * - Automatic API-key generation per IP, custody in a per-IP Durable Object so
    + *   concurrent first-requests mint at most ONE key (KV has no CAS — review M5).
      * - One API key per IP; the Relayer's usage limits reset every 24h on its side.
      * - Two submission modes: `{ func, auth }` (Address credentials) and `{ xdr }`
      *   (source-account auth, e.g. deploys).
    @@ -27,7 +28,6 @@ import {
     import {
       SERVICE_NAME,
       FRIENDBOT_URL,
    -  API_KEY_PREFIX,
       API_KEY_FIELD_NAMES,
       API_KEY_MIN_LENGTH,
       API_KEY_MAX_LENGTH,
    @@ -37,20 +37,18 @@ import {
       UNKNOWN_IP,
     } from "./constants";
     
    -interface StoredApiKey {
    -  apiKey: string;
    -  createdAt: number;
    -}
    -
    -interface ApiKeyReadResult {
    -  storedKey: StoredApiKey;
    -  needsMigration: boolean;
    -}
    +/** Durable Object storage key holding the IP's Relayer API key. */
    +const DO_KEY = "apiKey";
     
     // Hono app
     const app = new Hono<{ Bindings: Env }>();
     
    -// Enable CORS
    +// CORS is intentionally open: the browser calls this cross-origin and the
    +// endpoint holds no secret the caller could exfiltrate (it mints its own keys).
    +// SECURITY DECISION (endgame, before any MAINNET deploy): decide whether an
    +// open, keyless, fee-sponsoring `POST /` is acceptable on mainnet, or whether
    +// it needs an origin allowlist / auth / stricter rate limiting. On testnet the
    +// open policy is fine.
     app.use("*", cors());
     
     app.onError((error, c) => {
    @@ -69,22 +67,13 @@ app.onError((error, c) => {
     // ============================================================================
     
     /**
    - * Get the client IP from the request, preferring Cloudflare's trusted header.
    + * Get the client IP from the request. Uses ONLY `CF-Connecting-IP`, which
    + * Cloudflare always sets at the edge and the client cannot spoof. The
    + * `X-Forwarded-For` / `X-Real-IP` headers are client-controlled and must NOT be
    + * trusted for per-IP key custody (review LOW).
      */
     export function getClientIP(request: Request): string {
    -  return (
    -    request.headers.get(IP_HEADERS.CF_CONNECTING_IP) ||
    -    request.headers.get(IP_HEADERS.X_FORWARDED_FOR)?.split(",")[0]?.trim() ||
    -    request.headers.get(IP_HEADERS.X_REAL_IP) ||
    -    UNKNOWN_IP
    -  );
    -}
    -
    -/**
    - * Generate a unique KV key for an IP.
    - */
    -function getKVKey(ip: string): string {
    -  return `${API_KEY_PREFIX}${ip}`;
    +  return request.headers.get(IP_HEADERS.CF_CONNECTING_IP) || UNKNOWN_IP;
     }
     
     /**
    @@ -97,135 +86,6 @@ function isValidApiKey(apiKey: string): boolean {
       );
     }
     
    -/**
    - * Runtime type guard for stored API key records.
    - */
    -function isStoredApiKey(value: unknown): value is StoredApiKey {
    -  if (!value || typeof value !== "object") {
    -    return false;
    -  }
    -
    -  const candidate = value as Partial;
    -  return (
    -    typeof candidate.apiKey === "string" &&
    -    typeof candidate.createdAt === "number" &&
    -    isValidApiKey(candidate.apiKey)
    -  );
    -}
    -
    -/**
    - * Read and normalize a stored API key record.
    - *
    - * Supports legacy plain-text / JSON-string values and migrates them lazily.
    - */
    -async function readStoredApiKey(
    -  env: Env,
    -  kvKey: string
    -): Promise {
    -  try {
    -    const raw = await env.API_KEYS.get(kvKey);
    -    if (!raw) {
    -      return null;
    -    }
    -
    -    const trimmed = raw.trim();
    -    if (!trimmed) {
    -      console.error(`Empty API key value in KV for ${kvKey}. Deleting record.`);
    -      await env.API_KEYS.delete(kvKey);
    -      return null;
    -    }
    -
    -    try {
    -      const parsed = JSON.parse(trimmed) as unknown;
    -
    -      if (isStoredApiKey(parsed)) {
    -        return { storedKey: parsed, needsMigration: false };
    -      }
    -
    -      // Backward compatibility: JSON string value, e.g. "\"sk_...\""
    -      if (typeof parsed === "string" && isValidApiKey(parsed)) {
    -        return {
    -          storedKey: {
    -            apiKey: parsed.trim(),
    -            createdAt: Date.now(),
    -          },
    -          needsMigration: true,
    -        };
    -      }
    -    } catch {
    -      // Backward compatibility: plain text value, e.g. "sk_..."
    -      if (isValidApiKey(trimmed)) {
    -        return {
    -          storedKey: {
    -            apiKey: trimmed,
    -            createdAt: Date.now(),
    -          },
    -          needsMigration: true,
    -        };
    -      }
    -    }
    -
    -    console.error(
    -      `Corrupted API key KV value for ${kvKey}. Deleting invalid record.`
    -    );
    -    await env.API_KEYS.delete(kvKey);
    -    return null;
    -  } catch (error) {
    -    console.error(`Failed reading API key from KV for ${kvKey}:`, error);
    -    return null;
    -  }
    -}
    -
    -/**
    - * Get or generate an API key for the given IP.
    - * Keys are stored indefinitely — one key per IP address.
    - * The Relayer's usage limits reset every 24 hours on their side.
    - */
    -async function getOrCreateApiKey(
    -  env: Env,
    -  ip: string
    -): Promise<{ apiKey: string; isNew: boolean } | null> {
    -  const kvKey = getKVKey(ip);
    -
    -  // Check if we already have an API key for this IP.
    -  const cached = await readStoredApiKey(env, kvKey);
    -  if (cached) {
    -    if (cached.needsMigration) {
    -      try {
    -        await env.API_KEYS.put(kvKey, JSON.stringify(cached.storedKey));
    -      } catch (error) {
    -        console.error(
    -          `Failed migrating legacy API key format for ${kvKey}:`,
    -          error
    -        );
    -      }
    -    }
    -
    -    return { apiKey: cached.storedKey.apiKey, isNew: false };
    -  }
    -
    -  // No existing key — generate a new one from the Relayer's /gen endpoint.
    -  const newApiKey = await generateApiKey(env);
    -  if (!newApiKey) {
    -    return null;
    -  }
    -
    -  const storedKey: StoredApiKey = {
    -    apiKey: newApiKey,
    -    createdAt: Date.now(),
    -  };
    -
    -  // Store without expiration TTL — key persists until manually deleted.
    -  try {
    -    await env.API_KEYS.put(kvKey, JSON.stringify(storedKey));
    -  } catch (error) {
    -    console.error(`Failed storing API key in KV for ${kvKey}:`, error);
    -    return null;
    -  }
    -
    -  return { apiKey: newApiKey, isNew: true };
    -}
    -
     /**
      * Generate a new API key from the Relayer service. The /gen endpoint requires
      * no authentication (GET).
    @@ -248,10 +108,12 @@ async function generateApiKey(env: Env): Promise {
             if (typeof apiKey === "string") {
               return apiKey;
             }
    -        console.error("API key not found in response:", data);
    +        // Do NOT log the body: it may contain the key under an unexpected
    +        // field. Log only that extraction failed (review LOW: no secret logs).
    +        console.error("API key field not found in /gen response");
             return null;
           } catch {
    -        // Response might be a plain-text API key.
    +        // Response might be a plain-text API key — never log the raw text.
             if (
               text &&
               text.length > API_KEY_MIN_LENGTH &&
    @@ -259,12 +121,16 @@ async function generateApiKey(env: Env): Promise {
             ) {
               return text.trim();
             }
    -        console.error("Could not parse API key response:", text);
    +        console.error(
    +          `Could not parse /gen response (length ${text.length})`
    +        );
             return null;
           }
         }
     
    -    console.error("Failed to generate API key:", response.status, text);
    +    console.error(
    +      `Failed to generate API key: status ${response.status} (body length ${text.length})`
    +    );
         return null;
       } catch (error) {
         console.error("Error generating API key:", error);
    @@ -272,6 +138,69 @@ async function generateApiKey(env: Env): Promise {
       }
     }
     
    +/**
    + * Per-IP Relayer API key custody.
    + *
    + * One Durable Object instance per client IP (`idFromName(ip)`). All access to a
    + * given IP's key is serialized by the DO input gate + `blockConcurrencyWhile`,
    + * so N concurrent first-requests from one IP mint at most ONE `/gen` key. This
    + * is the atomic get-or-create KV cannot provide (KV has no CAS — review M5).
    + *
    + * Routes:
    + * - default (`/get`): get-or-create → 200 with the key, or 502 if minting fails.
    + * - `/peek`: report presence WITHOUT minting → `{ hasKey }` (for GET /status).
    + */
    +export class ApiKeyStore implements DurableObject {
    +  constructor(
    +    private readonly state: DurableObjectState,
    +    private readonly env: Env
    +  ) {}
    +
    +  async fetch(request: Request): Promise {
    +    const url = new URL(request.url);
    +
    +    if (url.pathname === "/peek") {
    +      const existing = await this.state.storage.get(DO_KEY);
    +      return Response.json({
    +        hasKey: !!existing && isValidApiKey(existing),
    +      });
    +    }
    +
    +    // Get-or-create, serialized per instance.
    +    const apiKey = await this.state.blockConcurrencyWhile(async () => {
    +      const existing = await this.state.storage.get(DO_KEY);
    +      if (existing && isValidApiKey(existing)) {
    +        return existing;
    +      }
    +      const minted = await generateApiKey(this.env);
    +      if (!minted) {
    +        return null;
    +      }
    +      await this.state.storage.put(DO_KEY, minted);
    +      return minted;
    +    });
    +
    +    if (!apiKey) {
    +      return new Response("Could not mint API key", { status: 502 });
    +    }
    +    return new Response(apiKey, { status: 200 });
    +  }
    +}
    +
    +/**
    + * Get (or lazily mint) the Relayer API key for an IP via its Durable Object.
    + */
    +async function getApiKeyForIp(env: Env, ip: string): Promise {
    +  const id = env.API_KEY_DO.idFromName(ip);
    +  const stub = env.API_KEY_DO.get(id);
    +  const res = await stub.fetch("https://api-key-store/get");
    +  if (!res.ok) {
    +    return null;
    +  }
    +  const apiKey = (await res.text()).trim();
    +  return isValidApiKey(apiKey) ? apiKey : null;
    +}
    +
     /**
      * Create a ChannelsClient for the configured network.
      */
    @@ -334,9 +263,9 @@ app.get("/", (c) => {
     app.post("/", async (c) => {
       try {
         const ip = getClientIP(c.req.raw);
    -    const apiKeyResult = await getOrCreateApiKey(c.env, ip);
    +    const apiKey = await getApiKeyForIp(c.env, ip);
     
    -    if (!apiKeyResult) {
    +    if (!apiKey) {
           return c.json(
             {
               success: false,
    @@ -376,7 +305,7 @@ app.post("/", async (c) => {
           );
         }
     
    -    const client = createClient(c.env, apiKeyResult.apiKey);
    +    const client = createClient(c.env, apiKey);
         const isTestnet = c.env.NETWORK === "testnet";
     
         // On testnet, retry for up to 5 minutes to handle channel accounts needing
    @@ -494,17 +423,19 @@ app.post("/", async (c) => {
     app.get("/status", async (c) => {
       try {
         const ip = getClientIP(c.req.raw);
    -    const kvKey = getKVKey(ip);
    -
    -    const apiKey = await readStoredApiKey(c.env, kvKey);
    +    const id = c.env.API_KEY_DO.idFromName(ip);
    +    const stub = c.env.API_KEY_DO.get(id);
    +    const res = await stub.fetch("https://api-key-store/peek");
    +    const peek = (res.ok ? await res.json() : { hasKey: false }) as {
    +      hasKey: boolean;
    +    };
     
         return c.json({
           success: true,
           data: {
             clientIP: ip,
             network: c.env.NETWORK,
    -        hasKey: !!apiKey,
    -        keyCreatedAt: apiKey?.storedKey.createdAt,
    +        hasKey: peek.hasKey,
           },
         });
       } catch (error) {
    diff --git a/relayer-proxy/worker-configuration.d.ts b/relayer-proxy/worker-configuration.d.ts
    index dd6c253..bd6749f 100644
    --- a/relayer-proxy/worker-configuration.d.ts
    +++ b/relayer-proxy/worker-configuration.d.ts
    @@ -4,8 +4,8 @@
     // `wrangler types` if you need them; this hand-written subset is all the worker
     // code references.
     interface Env {
    -  /** Per-IP Relayer API key cache. */
    -  API_KEYS: KVNamespace;
    +  /** Per-IP Relayer API key custody (serialized get-or-create). */
    +  API_KEY_DO: DurableObjectNamespace;
       /** Which network this instance serves. */
       NETWORK: "testnet" | "mainnet";
       /** OpenZeppelin Relayer Channels base URL for this network. */
    diff --git a/relayer-proxy/wrangler.toml b/relayer-proxy/wrangler.toml
    index 6ec5bfc..73446aa 100644
    --- a/relayer-proxy/wrangler.toml
    +++ b/relayer-proxy/wrangler.toml
    @@ -3,17 +3,17 @@ main = "src/index.ts"
     compatibility_date = "2024-12-01"
     compatibility_flags = ["nodejs_compat"]
     
    -# KV namespace for storing one Relayer API key per client IP.
    -# Provision before the first deploy and paste the returned id below:
    -#   wrangler kv namespace create API_KEYS
    -# (This repo ships a placeholder id — replace it; NO deploys happen from the
    -# services todo.)
    -[[kv_namespaces]]
    -binding = "API_KEYS"
    -id = "REPLACE_WITH_TESTNET_KV_NAMESPACE_ID"
    -# For `wrangler dev` you can add a preview namespace:
    -#   wrangler kv namespace create API_KEYS --preview
    -# preview_id = "REPLACE_WITH_PREVIEW_KV_NAMESPACE_ID"
    +# Per-IP Relayer API key custody. A Durable Object (one instance per client IP)
    +# serializes get-or-create so concurrent first-requests mint at most ONE key —
    +# the atomic get-or-create KV cannot provide (no CAS). No namespace id to
    +# provision; the class is bound directly.
    +[[durable_objects.bindings]]
    +name = "API_KEY_DO"
    +class_name = "ApiKeyStore"
    +
    +[[migrations]]
    +tag = "v1"
    +new_sqlite_classes = ["ApiKeyStore"]
     
     # Raise the CPU limit for testnet's Friendbot-funding retry loop (up to 5
     # minutes). Network wait time (fetch) does not count toward CPU time. Mainnet
    @@ -35,6 +35,10 @@ name = "passkey-kit-relayer-proxy-mainnet"
     NETWORK = "mainnet"
     RELAYER_BASE_URL = "https://channels.openzeppelin.com"
     
    -[[env.production.kv_namespaces]]
    -binding = "API_KEYS"
    -id = "REPLACE_WITH_MAINNET_KV_NAMESPACE_ID"
    +[[env.production.durable_objects.bindings]]
    +name = "API_KEY_DO"
    +class_name = "ApiKeyStore"
    +
    +[[env.production.migrations]]
    +tag = "v1"
    +new_sqlite_classes = ["ApiKeyStore"]
    diff --git a/zephyr/src/lib.rs b/zephyr/src/lib.rs
    index f61f058..6488cde 100644
    --- a/zephyr/src/lib.rs
    +++ b/zephyr/src/lib.rs
    @@ -86,15 +86,27 @@ pub extern "C" fn on_close() {
             }
         }
     
    -    // Pass 2 — ingest signer events from trusted wallets only. Event names are
    -    // matched as raw `ScVal::Symbol` values (deterministic structural byte
    -    // comparison) rather than via soroban `Symbol` equality, which for >9-char
    -    // symbols routes through env object comparison.
    +    // Pass 2 — fold signer events from trusted wallets into one pending state
    +    // per (address, key). Event names are matched as raw `ScVal::Symbol` values
    +    // (deterministic structural byte comparison) rather than via soroban
    +    // `Symbol` equality, which for >9-char symbols routes through env object
    +    // comparison.
    +    //
    +    // Folding is load-bearing (review M4): host db writes commit only AFTER
    +    // `on_close`, so a db_read inside this loop cannot observe this close's own
    +    // writes. Handling each event with its own read+write would let two
    +    // lifecycle events on the same key in one ledger corrupt the index — an
    +    // add+remove would leave the signer `active=true` forever, and two updates
    +    // would insert duplicate rows. Collapsing to a single write per key (in the
    +    // flush pass below) fixes both. This mirrors the in-memory `trusted` cache
    +    // the wallets table already uses for the same reason.
         let sym_added = sym_scval("signer_added");
         let sym_updated = sym_scval("signer_updated");
         let sym_removed = sym_scval("signer_removed");
         let sym_upgraded = sym_scval("upgraded");
     
    +    let mut pending: Vec = Vec::new();
    +
         for event in env.reader().pretty().soroban_events() {
             let topic0 = match event.topics.get(0) {
                 Some(t) => t,
    @@ -124,9 +136,8 @@ pub extern "C" fn on_close() {
                     Some(key) => key.clone(),
                     None => continue,
                 };
    -            match env.try_from_scval::(&event.data) {
    -                Ok(data) => ingest_signer(&env, address, key, data.val, data.storage),
    -                Err(_) => continue,
    +            if let Ok(data) = env.try_from_scval::(&event.data) {
    +                fold_upsert(&mut pending, &env, address, key, data.val, data.storage);
                 }
             } else if topic0 == &sym_updated {
                 let key = match event.topics.get(1) {
    @@ -137,9 +148,8 @@ pub extern "C" fn on_close() {
                 // `SignerUpdatedData` but needs no separate handling: the index
                 // keeps one row per (wallet, key) and rewrites `storage` in place,
                 // so a durability flip is captured by the new `storage` alone.
    -            match env.try_from_scval::(&event.data) {
    -                Ok(data) => ingest_signer(&env, address, key, data.val, data.storage),
    -                Err(_) => continue,
    +            if let Ok(data) = env.try_from_scval::(&event.data) {
    +                fold_upsert(&mut pending, &env, address, key, data.val, data.storage);
                 }
             } else if topic0 == &sym_removed {
                 let key = match event.topics.get(1) {
    @@ -147,10 +157,9 @@ pub extern "C" fn on_close() {
                     None => continue,
                 };
                 // Decode to validate the v1 `signer_removed` data shape (Map{storage}).
    -            if env.try_from_scval::(&event.data).is_err() {
    -                continue;
    +            if env.try_from_scval::(&event.data).is_ok() {
    +                fold_remove(&mut pending, address, key);
                 }
    -            deactivate_signer(&env, address, key);
             } else {
                 // `upgraded`: refresh the (already-trusted) wallet's WASM hash.
                 if let Ok(data) = env.try_from_scval::(&event.data) {
    @@ -158,6 +167,12 @@ pub extern "C" fn on_close() {
                 }
             }
         }
    +
    +    // Pass 3 — flush each key's net state: one prior-ledger existence read and
    +    // one write per (address, key).
    +    for signer in pending {
    +        flush_signer(&env, signer);
    +    }
     }
     
     // --- ingestion helpers ------------------------------------------------------
    @@ -199,8 +214,53 @@ fn refresh_wallet_hash(env: &EnvClient, address: &ScVal, wasm_hash: ScVal) {
             .unwrap();
     }
     
    -/// Decode a signer value into its indexable columns and upsert the row.
    -fn ingest_signer(
    +/// The folded net state of one (address, key) signer across a single ledger
    +/// close (review M4). Written exactly once, in `flush_signer`.
    +struct PendingSigner {
    +    address: ScVal,
    +    key: ScVal,
    +    /// Columns from the last add/update seen this close. `None` when only
    +    /// removals were seen — the row, if any, lives in a prior ledger and only
    +    /// its `active` flag is flipped.
    +    cols: Option,
    +    active: bool,
    +}
    +
    +struct SignerCols {
    +    val: ScVal,
    +    limits: ScVal,
    +    exp: i64,
    +    storage: ScVal,
    +}
    +
    +/// Get the pending entry for (address, key), creating it if absent. Linear scan
    +/// is fine: the number of distinct signers touched in one ledger is tiny.
    +fn pending_entry<'a>(
    +    pending: &'a mut Vec,
    +    address: &ScVal,
    +    key: &ScVal,
    +) -> &'a mut PendingSigner {
    +    if let Some(i) = pending
    +        .iter()
    +        .position(|p| &p.address == address && &p.key == key)
    +    {
    +        &mut pending[i]
    +    } else {
    +        pending.push(PendingSigner {
    +            address: address.clone(),
    +            key: key.clone(),
    +            cols: None,
    +            active: false,
    +        });
    +        let last = pending.len() - 1;
    +        &mut pending[last]
    +    }
    +}
    +
    +/// Fold an add/update into the pending state: last write wins on columns, and
    +/// the signer becomes active.
    +fn fold_upsert(
    +    pending: &mut Vec,
         env: &EnvClient,
         address: ScVal,
         key: ScVal,
    @@ -208,76 +268,80 @@ fn ingest_signer(
         storage: SignerStorage,
     ) {
         let (public_key, expiration, limits) = signer_val.into_parts();
    -    let val = match public_key {
    -        Some(public_key) => env.to_scval(public_key),
    -        None => ScVal::Void,
    +    let cols = SignerCols {
    +        val: match public_key {
    +            Some(public_key) => env.to_scval(public_key),
    +            None => ScVal::Void,
    +        },
    +        limits: env.to_scval(limits),
    +        exp: expiration.0.map(|t| t as i64).unwrap_or(EXP_NEVER),
    +        storage: env.to_scval(storage),
         };
    -    let exp = expiration.0.map(|t| t as i64).unwrap_or(EXP_NEVER);
    -    upsert_signer(
    -        env,
    -        address,
    -        key,
    -        val,
    -        env.to_scval(limits),
    -        exp,
    -        env.to_scval(storage),
    -    );
    +    let entry = pending_entry(pending, &address, &key);
    +    entry.cols = Some(cols);
    +    entry.active = true;
     }
     
    -fn upsert_signer(
    -    env: &EnvClient,
    -    address: ScVal,
    -    key: ScVal,
    -    val: ScVal,
    -    limits: ScVal,
    -    exp: i64,
    -    storage: ScVal,
    -) {
    -    let existing: Vec = env
    -        .read_filter()
    -        .column_equal_to_xdr("address", &address)
    -        .column_equal_to_xdr("key", &key)
    -        .read()
    -        .unwrap();
    -    if existing.is_empty() {
    -        env.put(&Signer {
    -            address,
    -            key,
    -            val,
    -            limits,
    -            exp,
    -            storage,
    -            active: true,
    -        });
    -    } else {
    -        env.update()
    -            .column_equal_to_xdr("address", &address)
    -            .column_equal_to_xdr("key", &key)
    -            .execute(&SignerMutation {
    -                val,
    -                limits,
    -                exp,
    -                storage,
    -                active: true,
    -            })
    -            .unwrap();
    +/// Fold a removal into the pending state: the signer becomes inactive. Any
    +/// columns from an earlier same-close add/update are retained so a
    +/// same-close add+remove still writes a complete (inactive) row.
    +fn fold_remove(pending: &mut Vec, address: ScVal, key: ScVal) {
    +    pending_entry(pending, &address, &key).active = false;
    +}
    +
    +/// Commit one folded signer with a single existence read + single write.
    +fn flush_signer(env: &EnvClient, signer: PendingSigner) {
    +    let exists = signer_exists(env, &signer.address, &signer.key);
    +    match signer.cols {
    +        Some(cols) => {
    +            if exists {
    +                env.update()
    +                    .column_equal_to_xdr("address", &signer.address)
    +                    .column_equal_to_xdr("key", &signer.key)
    +                    .execute(&SignerMutation {
    +                        val: cols.val,
    +                        limits: cols.limits,
    +                        exp: cols.exp,
    +                        storage: cols.storage,
    +                        active: signer.active,
    +                    })
    +                    .unwrap();
    +            } else {
    +                env.put(&Signer {
    +                    address: signer.address,
    +                    key: signer.key,
    +                    val: cols.val,
    +                    limits: cols.limits,
    +                    exp: cols.exp,
    +                    storage: cols.storage,
    +                    active: signer.active,
    +                });
    +            }
    +        }
    +        // Only removals seen this close (so `active` is false). Deactivate an
    +        // existing prior-ledger row; nothing to do if none exists.
    +        None => {
    +            if exists {
    +                env.update()
    +                    .column_equal_to_xdr("address", &signer.address)
    +                    .column_equal_to_xdr("key", &signer.key)
    +                    .execute(&SignerActiveMutation {
    +                        active: signer.active,
    +                    })
    +                    .unwrap();
    +            }
    +        }
         }
     }
     
    -fn deactivate_signer(env: &EnvClient, address: ScVal, key: ScVal) {
    -    let existing: Vec = env
    +fn signer_exists(env: &EnvClient, address: &ScVal, key: &ScVal) -> bool {
    +    let rows: Vec = env
             .read_filter()
    -        .column_equal_to_xdr("address", &address)
    -        .column_equal_to_xdr("key", &key)
    +        .column_equal_to_xdr("address", address)
    +        .column_equal_to_xdr("key", key)
             .read()
             .unwrap();
    -    if !existing.is_empty() {
    -        env.update()
    -            .column_equal_to_xdr("address", &address)
    -            .column_equal_to_xdr("key", &key)
    -            .execute(&SignerActiveMutation { active: false })
    -            .unwrap();
    -    }
    +    !rows.is_empty()
     }
     
     // ---------------------------------------------------------------------------
    @@ -316,6 +380,12 @@ pub extern "C" fn get_signers_by_address() {
         let SignersByAddressRequest { address } = env.read_request_body();
         let now = env.soroban().ledger().timestamp();
     
    +    // Validate the wallet C-address before building it: `address_from_str`
    +    // panics on malformed input and this profile aborts on panic (review LOW).
    +    if stellar_strkey::Contract::from_string(&address).is_err() {
    +        env.conclude::>(Vec::new());
    +        return;
    +    }
         let address = env.to_scval(address_from_str(&env, address.as_str()));
         let active_true = bincode::serialize(&true).unwrap();
     
    @@ -388,16 +458,29 @@ pub extern "C" fn get_addresses_by_signer() {
         let AddressBySignerRequest { key, kind } = env.read_request_body();
         let now = env.soroban().ledger().timestamp();
     
    +    // Parse caller-supplied key material fallibly: unparseable input concludes
    +    // empty rather than panicking (which would abort under panic=abort). (LOW)
         let key_scval = match kind.as_str() {
    -        "Policy" => env.to_scval(SignerKey::Policy(address_from_str(&env, key.as_str()))),
    -        "Ed25519" => {
    -            let raw = stellar_strkey::ed25519::PublicKey::from_string(&key).unwrap().0;
    -            env.to_scval(SignerKey::Ed25519(BytesN::from_array(env.soroban(), &raw)))
    -        }
    -        "Secp256r1" => {
    -            let raw = URL_SAFE_NO_PAD.decode(key).unwrap();
    -            env.to_scval(SignerKey::Secp256r1(Bytes::from_slice(env.soroban(), &raw)))
    +        "Policy" if is_valid_address_str(&key) => {
    +            env.to_scval(SignerKey::Policy(address_from_str(&env, key.as_str())))
             }
    +        "Ed25519" => match stellar_strkey::ed25519::PublicKey::from_string(&key) {
    +            Ok(public_key) => env.to_scval(SignerKey::Ed25519(BytesN::from_array(
    +                env.soroban(),
    +                &public_key.0,
    +            ))),
    +            Err(_) => {
    +                env.conclude::>(Vec::new());
    +                return;
    +            }
    +        },
    +        "Secp256r1" => match URL_SAFE_NO_PAD.decode(&key) {
    +            Ok(raw) => env.to_scval(SignerKey::Secp256r1(Bytes::from_slice(env.soroban(), &raw))),
    +            Err(_) => {
    +                env.conclude::>(Vec::new());
    +                return;
    +            }
    +        },
             _ => {
                 env.conclude::>(Vec::new());
                 return;
    @@ -430,6 +513,13 @@ pub extern "C" fn get_addresses_by_signer() {
     
     // --- read helpers -----------------------------------------------------------
     
    +/// Whether a string is a valid Stellar address strkey (contract or ed25519).
    +/// Guards `address_from_str`, which panics on invalid input.
    +fn is_valid_address_str(s: &str) -> bool {
    +    stellar_strkey::Contract::from_string(s).is_ok()
    +        || stellar_strkey::ed25519::PublicKey::from_string(s).is_ok()
    +}
    +
     fn storage_string(storage: SignerStorage) -> String {
         match storage {
             SignerStorage::Persistent => "Persistent".to_string(),
    
    From b5238baa6877477542df20eac586f554da19c546 Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sat, 11 Jul 2026 23:27:41 -0400
    Subject: [PATCH 25/30] =?UTF-8?q?fix(sdk):=20F1b=20code-review=20gate=20?=
     =?UTF-8?q?=E2=80=94=20H2=20eviction=20probe,=20M1/M2,=20LOW=20hardening?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Fixes the SDK findings from the F1b /code-review gate (todo 966, comment 2496).
    No contract wasm change; no hash re-pin.
    
    H2 [HIGH] — the eviction probe queried the WRONG ledger key. mercury.ts and
    server.ts probed getContractData(wallet, scvBytes(keyId), Temporary), but the
    contract keys signer entries by the SignerKey ScVal vec (signer.rs
    set::), so every probe missed -> catch -> every live
    temporary signer was reported "evicted". Now probe by
    signerKeyToContractScVal(...) — the exact key getSigner uses. getContractData
    also collapses not-found AND transport errors into one {code:404}, so eviction
    could not be told from a flaky RPC: added src/rpc-data.ts, which reads via
    getLedgerEntries (empty entries = genuine not-found; a throw = transport) and
    concludes eviction ONLY on an authoritative not-found, never on 429/5xx/timeout.
    
    M1 [MED] — codec limitsOf encoded a limits-nested Ed25519 key as HEX, diverging
    from scValToSignerKey / types.ts (a G-address); the hex value threw in
    Keypair.fromPublicKey on re-encode. Now StrKey.encodeEd25519PublicKey. Fixing
    this surfaced an adjacent bug: spec.scValToNative decodes an ScMap to an ARRAY
    of [key,value] pairs (not a JS Map), so limitsOf's `map.entries()` mis-keyed
    every entry under a numeric index — limits decoding was broken outright.
    Switched to direct `for..of map` (correct for both an array-of-pairs and a real
    Map).
    
    M2 [MED] — relayer toResult used a failure DENYLIST, so a non-terminal "pending"
    status mapped to success:true and a poll loop would treat an unconfirmed (or
    later-reverted) tx as confirmed. Now a terminal-success ALLOWLIST
    (/confirm|success/i); a pending status is a distinct non-success result carrying
    the new RELAYER_PENDING code + `pending:true` context. The binary
    TransactionResult union is preserved deliberately (a structural third arm would
    ripple into the out-of-scope demo consumers).
    
    LOW:
    - stellar-indexer extractEntries: throw IndexerError on an unrecognized non-empty
      envelope instead of degrading to [] (empty-vs-error conflation burned the retry
      budget then reported "no signers"). null / {} still map to [].
    - wallet-ops getSigner: transport errors propagate instead of being swallowed to
      null, so a transient RPC failure during connectWallet's ownership check no
      longer throws a false WalletOwnershipError for a valid passkey; connectWallet
      clears the half-connected state and rethrows the truthful error.
    - tx-ops: `expiration == null` instead of `!expiration` (an explicit 0 is honored).
    
    Tests: +9 (mercury probe-key/evict/transport, relayer pending, codec limits
    round-trip, stellar-indexer response shapes, getSigner transport-propagation).
    pnpm build + npx vitest run = 141 tests / 17 files green.
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     src/errors.ts                       |  5 +-
     src/indexer/codec.test.ts           | 25 +++++++++
     src/indexer/codec.ts                | 12 ++++-
     src/indexer/mercury.test.ts         | 80 ++++++++++++++++++++++++++++-
     src/indexer/mercury.ts              | 33 ++++++++----
     src/indexer/stellar-indexer.test.ts | 37 ++++++++++++-
     src/indexer/stellar-indexer.ts      | 24 +++++++--
     src/kit.ts                          | 16 ++++--
     src/kit/tx-ops.ts                   |  5 +-
     src/kit/wallet-ops.ts               | 13 +++--
     src/managers/signer-manager.test.ts | 23 +++++++--
     src/relayer.test.ts                 | 24 +++++++++
     src/relayer.ts                      | 40 ++++++++++++---
     src/rpc-data.ts                     | 71 +++++++++++++++++++++++++
     src/server.ts                       | 30 ++++++++---
     15 files changed, 390 insertions(+), 48 deletions(-)
     create mode 100644 src/rpc-data.ts
    
    diff --git a/src/errors.ts b/src/errors.ts
    index 6e79943..de5c018 100644
    --- a/src/errors.ts
    +++ b/src/errors.ts
    @@ -67,6 +67,8 @@ export enum PasskeyKitErrorCode {
       // Relayer (7xxx)
       RELAYER_NOT_CONFIGURED = 7001,
       RELAYER_REQUEST_FAILED = 7002,
    +  /** Submitted but not yet in a terminal state — keep polling getTransaction. */
    +  RELAYER_PENDING = 7003,
     
       // Validation (8xxx)
       INVALID_ADDRESS = 8001,
    @@ -271,7 +273,8 @@ export class RelayerError extends PasskeyKitError {
         message: string,
         code:
           | PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED
    -      | PasskeyKitErrorCode.RELAYER_REQUEST_FAILED = PasskeyKitErrorCode.RELAYER_REQUEST_FAILED,
    +      | PasskeyKitErrorCode.RELAYER_REQUEST_FAILED
    +      | PasskeyKitErrorCode.RELAYER_PENDING = PasskeyKitErrorCode.RELAYER_REQUEST_FAILED,
         context?: Record,
         cause?: Error
       ) {
    diff --git a/src/indexer/codec.test.ts b/src/indexer/codec.test.ts
    index b56272c..3c8c72e 100644
    --- a/src/indexer/codec.test.ts
    +++ b/src/indexer/codec.test.ts
    @@ -11,6 +11,7 @@ import {
       signerKeyToIndexerJson,
       walletSpec,
     } from "./codec.js";
    +import { toContractSignerKey } from "../kit/wallet-ops.js";
     
     /** Encode a native SignerVal to its ScVal via the contract spec. */
     function encodeSignerVal(native: unknown): xdr.ScVal {
    @@ -44,6 +45,30 @@ describe("decodeSignerVal", () => {
       });
     });
     
    +describe("decodeSignerVal — limits-nested keys (audit M1)", () => {
    +  it("decodes a limits-nested Ed25519 key as a G-address that round-trips", () => {
    +    const g = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 4)).publicKey();
    +    const rawPk = Keypair.fromPublicKey(g).rawPublicKey();
    +    const contract = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +    // SignerVal Ed25519 = [ SignerExpiration, SignerLimits ]; limits = Some({
    +    // contract -> Some([ Ed25519 key ]) }).
    +    const scVal = encodeSignerVal({
    +      tag: "Ed25519",
    +      values: [
    +        [undefined],
    +        [new Map([[contract, [{ tag: "Ed25519", values: [rawPk] }]]])],
    +      ],
    +    });
    +
    +    const nested = decodeSignerVal(scVal).limits!.get(contract)![0]!;
    +    expect(nested.key).toBe("Ed25519");
    +    expect(StrKey.isValidEd25519PublicKey(nested.value)).toBe(true);
    +    expect(nested.value).toBe(g); // a G-address, NOT hex
    +    // The old hex encoding threw here (Keypair.fromPublicKey(hex)).
    +    expect(() => toContractSignerKey(nested)).not.toThrow();
    +  });
    +});
    +
     describe("scValToSignerKey", () => {
       it("round-trips a Secp256r1 key", () => {
         const keyId = base64url.encode(Buffer.alloc(16, 0xab));
    diff --git a/src/indexer/codec.ts b/src/indexer/codec.ts
    index 1cef47e..7a26dca 100644
    --- a/src/indexer/codec.ts
    +++ b/src/indexer/codec.ts
    @@ -130,7 +130,12 @@ function limitsOf(native: SignerVal): SignerLimits {
       if (!map) return undefined;
     
       const out: SignerLimits = new Map();
    -  for (const [contract, keys] of map.entries()) {
    +  // `spec.scValToNative` decodes an ScMap to an ARRAY of `[key, value]` pairs
    +  // (spec.js), not a JS Map — despite the `Map` static type. Iterate the pairs
    +  // directly; `map.entries()` would yield `[index, pair]` and mis-key every
    +  // entry under a numeric index. Direct iteration is correct for both an
    +  // array-of-pairs and a real Map, so it stays robust if the SDK ever changes.
    +  for (const [contract, keys] of map) {
         out.set(
           contract,
           keys
    @@ -139,8 +144,11 @@ function limitsOf(native: SignerVal): SignerLimits {
                   case "Policy":
                     return SignerKey.Policy(k.values[0]);
                   case "Ed25519":
    +                // Ed25519 SignerKey.value is a `G…` strkey (matches
    +                // scValToSignerKey and types.ts), NOT hex — a hex value throws
    +                // in Keypair.fromPublicKey when re-encoded (audit M1).
                     return SignerKey.Ed25519(
    -                  Buffer.from(k.values[0]).toString("hex")
    +                  StrKey.encodeEd25519PublicKey(Buffer.from(k.values[0]))
                     );
                   case "Secp256r1":
                     return SignerKey.Secp256r1(
    diff --git a/src/indexer/mercury.test.ts b/src/indexer/mercury.test.ts
    index 760b6b1..6b9b9f1 100644
    --- a/src/indexer/mercury.test.ts
    +++ b/src/indexer/mercury.test.ts
    @@ -1,11 +1,11 @@
     import { afterEach, describe, expect, it, vi } from "vitest";
    -import { Keypair } from "@stellar/stellar-sdk";
    +import { Keypair, xdr } from "@stellar/stellar-sdk";
     import base64url from "../base64url.js";
     import { SignerKey } from "../types.js";
     import { SIGNER_VAL_UDT } from "../kit/auth-payload.js";
     import { deriveContractAddress } from "../utils.js";
     import { MercuryIndexer, type MercurySignerRow } from "./mercury.js";
    -import { walletSpec } from "./codec.js";
    +import { signerKeyToContractScVal, walletSpec } from "./codec.js";
     
     const TESTNET = "Test SDF Network ; September 2015";
     const DEPLOYER = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 9)).publicKey();
    @@ -67,6 +67,82 @@ describe("MercuryIndexer.getSigners", () => {
       });
     });
     
    +describe("MercuryIndexer eviction probe (audit H2)", () => {
    +  const WALLET = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +  const keyId = base64url.encode(Buffer.alloc(16, 5));
    +
    +  function tempRow(): MercurySignerRow {
    +    return {
    +      kind: "Secp256r1",
    +      key: keyId,
    +      val: secp256r1ValXdr(4_000_000_000),
    +      storage: "Temporary",
    +    };
    +  }
    +
    +  function fakeRpc(getLedgerEntries: ReturnType) {
    +    return { getLedgerEntries } as never;
    +  }
    +
    +  it("probes by the SignerKey ScVal (not raw keyId bytes) and keeps a live temporary signer", async () => {
    +    stubFetch({ get_signers_by_address: [tempRow()] });
    +    // A present entry: readContractData resolves a non-null ScVal.
    +    const present = { contractData: () => ({ val: () => xdr.ScVal.scvVoid() }) };
    +    const getLedgerEntries = vi.fn(async () => ({ entries: [{ val: present }] }));
    +    const indexer = new MercuryIndexer({
    +      url: "https://mercury.test",
    +      projectName: "proj",
    +      jwt: "jwt",
    +      now: () => 1000,
    +      rpc: fakeRpc(getLedgerEntries),
    +    });
    +
    +    const signers = await indexer.getSigners(WALLET);
    +
    +    expect(signers[0]!.status).toBe("live");
    +    expect(getLedgerEntries).toHaveBeenCalledTimes(1);
    +    // The probed ledger key must carry the SignerKey vec, NOT scvBytes(keyId).
    +    const ledgerKey = getLedgerEntries.mock.calls[0]![0] as xdr.LedgerKey;
    +    const probedKey = ledgerKey.contractData().key();
    +    expect(probedKey.toXDR("base64")).toBe(
    +      signerKeyToContractScVal(SignerKey.Secp256r1(keyId)).toXDR("base64")
    +    );
    +    expect(probedKey.switch().name).toBe("scvVec");
    +  });
    +
    +  it("marks a temporary signer evicted only on a genuine not-found", async () => {
    +    stubFetch({ get_signers_by_address: [tempRow()] });
    +    const indexer = new MercuryIndexer({
    +      url: "https://mercury.test",
    +      projectName: "proj",
    +      jwt: "jwt",
    +      now: () => 1000,
    +      rpc: fakeRpc(vi.fn(async () => ({ entries: [] }))),
    +    });
    +
    +    const signers = await indexer.getSigners(WALLET);
    +    expect(signers[0]!.status).toBe("evicted");
    +  });
    +
    +  it("does NOT evict on a transport error during the probe", async () => {
    +    stubFetch({ get_signers_by_address: [tempRow()] });
    +    const indexer = new MercuryIndexer({
    +      url: "https://mercury.test",
    +      projectName: "proj",
    +      jwt: "jwt",
    +      now: () => 1000,
    +      rpc: fakeRpc(
    +        vi.fn(async () => {
    +          throw new Error("429 too many requests");
    +        })
    +      ),
    +    });
    +
    +    const signers = await indexer.getSigners(WALLET);
    +    expect(signers[0]!.status).toBe("live"); // left as reported, not false-evicted
    +  });
    +});
    +
     describe("MercuryIndexer.findWallets hardening", () => {
       it("keeps only the deterministically-derived candidate when no rpc is available", async () => {
         const keyId = base64url.encode(Buffer.alloc(16, 7));
    diff --git a/src/indexer/mercury.ts b/src/indexer/mercury.ts
    index dff58e2..c22da82 100644
    --- a/src/indexer/mercury.ts
    +++ b/src/indexer/mercury.ts
    @@ -19,6 +19,7 @@ import { SignerKey } from "../types.js";
     import { IndexerError, PasskeyKitErrorCode } from "../errors.js";
     import { DEFAULT_INDEXER_TIMEOUT_MS } from "../constants.js";
     import { getSigner } from "../kit/wallet-ops.js";
    +import { contractDataExists } from "../rpc-data.js";
     import base64url from "../base64url.js";
     import type {
       FindWalletsHardeningDeps,
    @@ -31,6 +32,7 @@ import {
       buildWalletSigner,
       decodeSignerVal,
       deriveStatus,
    +  signerKeyToContractScVal,
       walletSpec,
     } from "./codec.js";
     import { deriveContractAddress } from "../utils.js";
    @@ -144,7 +146,13 @@ export class MercuryIndexer implements SignerIndexer {
         if (this.config.rpc) {
           for (const row of rows) {
             if (row.storage === "Temporary" && !row.evicted) {
    -          row.evicted = !(await this.entryExists(wallet, row));
    +          try {
    +            row.evicted = !(await this.entryExists(wallet, row));
    +          } catch {
    +            // Transport error (429/5xx/timeout): eviction is undeterminable, so
    +            // leave the row as the indexer reported it rather than
    +            // false-evicting a live signer.
    +          }
             }
           }
         }
    @@ -152,20 +160,23 @@ export class MercuryIndexer implements SignerIndexer {
         return rows.map((row) => this.rowToWalletSigner(row));
       }
     
    +  /**
    +   * Whether the signer's temporary ledger entry still exists on-chain. Probes by
    +   * the `SignerKey` ScVal — the exact key the contract stores the entry under
    +   * (`storage().temporary().set::`), NOT the raw keyId
    +   * bytes — so it actually matches. Throws on a transport error (caller decides
    +   * eviction only from a genuine not-found).
    +   */
       private async entryExists(
         wallet: string,
         row: MercurySignerRow
       ): Promise {
    -    try {
    -      await this.config.rpc!.getContractData(
    -        wallet,
    -        xdr.ScVal.scvBytes(base64url.toBuffer(row.key)),
    -        Durability.Temporary
    -      );
    -      return true;
    -    } catch {
    -      return false;
    -    }
    +    return contractDataExists(
    +      this.config.rpc!,
    +      wallet,
    +      signerKeyToContractScVal(signerKeyFromRow(row)),
    +      Durability.Temporary
    +    );
       }
     
       async findWallets(key: SignerKey): Promise {
    diff --git a/src/indexer/stellar-indexer.test.ts b/src/indexer/stellar-indexer.test.ts
    index cf195a2..d23cbe3 100644
    --- a/src/indexer/stellar-indexer.test.ts
    +++ b/src/indexer/stellar-indexer.test.ts
    @@ -1,7 +1,8 @@
    -import { describe, expect, it } from "vitest";
    +import { afterEach, describe, expect, it, vi } from "vitest";
     import { xdr } from "@stellar/stellar-sdk";
     import base64url from "../base64url.js";
     import { SignerKey } from "../types.js";
    +import { IndexerError } from "../errors.js";
     import { SIGNER_VAL_UDT } from "../kit/auth-payload.js";
     import {
       StellarIndexerBackend,
    @@ -83,3 +84,37 @@ describe("StellarIndexerBackend.findWallets", () => {
         expect(result).toEqual([]);
       });
     });
    +
    +describe("StellarIndexerBackend response-shape handling (audit LOW)", () => {
    +  const WALLET = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +
    +  afterEach(() => vi.unstubAllGlobals());
    +
    +  function stubResponse(json: unknown) {
    +    vi.stubGlobal(
    +      "fetch",
    +      vi.fn(async () => ({
    +        ok: true,
    +        status: 200,
    +        json: async () => json,
    +        text: async () => "",
    +      }))
    +    );
    +  }
    +
    +  it("throws IndexerError on an unrecognized non-empty response shape", async () => {
    +    // A silent [] here would report a real wallet as signer-less after burning
    +    // the retry budget.
    +    stubResponse({ results: [{ key: "x" }] });
    +    await expect(backend().getSigners(WALLET)).rejects.toBeInstanceOf(
    +      IndexerError
    +    );
    +  });
    +
    +  it("treats a genuinely-empty object/array as no entries", async () => {
    +    stubResponse({});
    +    expect(await backend().getSigners(WALLET)).toEqual([]);
    +    stubResponse({ entries: [] });
    +    expect(await backend().getSigners(WALLET)).toEqual([]);
    +  });
    +});
    diff --git a/src/indexer/stellar-indexer.ts b/src/indexer/stellar-indexer.ts
    index 27919c0..6833e01 100644
    --- a/src/indexer/stellar-indexer.ts
    +++ b/src/indexer/stellar-indexer.ts
    @@ -202,11 +202,25 @@ export class StellarIndexerBackend implements SignerIndexer {
       }
     }
     
    -/** Tolerantly pull the entries array out of a few likely response shapes. */
    +/**
    + * Pull the entries array out of a few known response shapes.
    + *
    + * A genuinely empty response (`null`/`undefined` or `{}`) yields `[]`, but an
    + * UNRECOGNIZED non-empty envelope throws rather than silently degrading to `[]`:
    + * conflating "shape changed" with "wallet has no signers" would burn the whole
    + * retry budget and then report a valid wallet as signer-less (audit LOW).
    + */
     function extractEntries(json: unknown): StellarIndexerEntry[] {
       if (Array.isArray(json)) return json as StellarIndexerEntry[];
    -  const obj = json as { entries?: unknown; data?: unknown } | null;
    -  if (obj && Array.isArray(obj.entries)) return obj.entries as StellarIndexerEntry[];
    -  if (obj && Array.isArray(obj.data)) return obj.data as StellarIndexerEntry[];
    -  return [];
    +  if (json == null) return [];
    +  if (typeof json === "object") {
    +    const obj = json as { entries?: unknown; data?: unknown };
    +    if (Array.isArray(obj.entries)) return obj.entries as StellarIndexerEntry[];
    +    if (Array.isArray(obj.data)) return obj.data as StellarIndexerEntry[];
    +    if (Object.keys(obj).length === 0) return []; // genuinely-empty object
    +  }
    +  throw new IndexerError(
    +    "Unrecognized Stellar Indexer response shape (expected an array or { entries | data: [...] })",
    +    PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +  );
     }
    diff --git a/src/kit.ts b/src/kit.ts
    index a3f388d..3103df8 100644
    --- a/src/kit.ts
    +++ b/src/kit.ts
    @@ -307,9 +307,19 @@ export class PasskeyKit {
         this.keyId = keyIdBase64;
     
         // 3) Ownership verification (F7): the keyId must be a live signer.
    -    const signerVal = await this.signerManager.getSigner(
    -      SignerKey.Secp256r1(keyIdBase64)
    -    );
    +    // A thrown error here is a transport failure, not proof of non-ownership —
    +    // surface it as-is (a valid passkey must not read as an ownership mismatch
    +    // because the RPC hiccuped); only a definitive not-found (`null`) does.
    +    let signerVal;
    +    try {
    +      signerVal = await this.signerManager.getSigner(
    +        SignerKey.Secp256r1(keyIdBase64)
    +      );
    +    } catch (err) {
    +      this.wallet = undefined;
    +      this.keyId = undefined;
    +      throw err;
    +    }
         if (!signerVal) {
           this.wallet = undefined;
           this.keyId = undefined;
    diff --git a/src/kit/tx-ops.ts b/src/kit/tx-ops.ts
    index cf4a663..6c16714 100644
    --- a/src/kit/tx-ops.ts
    +++ b/src/kit/tx-ops.ts
    @@ -83,8 +83,11 @@ export async function signAuthEntry(
     
       const credentials = getAddressCredentials(entry.credentials());
     
    +  // `== null`, not `!expiration`: an explicit `expiration: 0` is a caller-chosen
    +  // value, not "unset" — only undefined/null falls through to the entry's
    +  // existing ledger or a freshly computed default.
       let expiration = options?.expiration;
    -  if (!expiration) {
    +  if (expiration == null) {
         expiration = credentials.signatureExpirationLedger();
         if (!expiration) {
           expiration = await deps.calculateExpiration();
    diff --git a/src/kit/wallet-ops.ts b/src/kit/wallet-ops.ts
    index c68d349..9a704e9 100644
    --- a/src/kit/wallet-ops.ts
    +++ b/src/kit/wallet-ops.ts
    @@ -30,6 +30,7 @@ import {
     } from "passkey-kit-sdk";
     import base64url from "../base64url.js";
     import { SignerStore, type SignerKey, type SignerLimits } from "../types.js";
    +import { readContractData } from "../rpc-data.js";
     import { signerKeyToScVal, SIGNER_VAL_UDT } from "./auth-payload.js";
     
     /**
    @@ -210,6 +211,11 @@ export function buildUpgradeTx(
      * Checks temporary durability before persistent (the contract's lookup order).
      * A non-null result proves the wallet holds this signer — the basis of
      * `connectWallet`'s ownership verification.
    + *
    + * `null` means a genuine not-found in BOTH durabilities. A transport error
    + * (RPC 429/5xx/timeout) is NOT swallowed — it propagates, so `connectWallet`
    + * never mistakes a flaky RPC for "the passkey is not a signer" and throws a
    + * misleading `WalletOwnershipError` for a valid credential.
      */
     export async function getSigner(
       deps: { rpc: Server; spec: ContractSpec },
    @@ -219,12 +225,9 @@ export async function getSigner(
       const scKey = signerKeyToScVal(deps.spec, toContractSignerKey(signerKey));
     
       for (const durability of [Durability.Temporary, Durability.Persistent]) {
    -    try {
    -      const entry = await deps.rpc.getContractData(contractId, scKey, durability);
    -      const scVal = entry.val.contractData().val();
    +    const scVal = await readContractData(deps.rpc, contractId, scKey, durability);
    +    if (scVal) {
           return deps.spec.scValToNative(scVal, SIGNER_VAL_UDT) as SignerVal;
    -    } catch {
    -      // Not present in this durability class; try the next.
         }
       }
     
    diff --git a/src/managers/signer-manager.test.ts b/src/managers/signer-manager.test.ts
    index 2d018a6..3222932 100644
    --- a/src/managers/signer-manager.test.ts
    +++ b/src/managers/signer-manager.test.ts
    @@ -29,9 +29,8 @@ function makeDeps(overrides: Partial = {}) {
         remove_signer: vi.fn(async () => "AT_REMOVE"),
       };
       const rpc = {
    -    getContractData: vi.fn(async () => {
    -      throw new Error("not found");
    -    }),
    +    // getSigner probes via getLedgerEntries: empty entries = genuine not-found.
    +    getLedgerEntries: vi.fn(async () => ({ entries: [] as unknown[] })),
       };
       const deps: SignerManagerDeps = {
         networkPassphrase: TESTNET,
    @@ -124,6 +123,22 @@ describe("SignerManager.getSigner", () => {
         );
     
         expect(result).toBeNull();
    -    expect(rpc.getContractData).toHaveBeenCalledTimes(2); // temporary + persistent
    +    expect(rpc.getLedgerEntries).toHaveBeenCalledTimes(2); // temporary + persistent
    +  });
    +
    +  it("propagates a transport error instead of reporting the signer absent", async () => {
    +    // A transient RPC failure must NOT be swallowed into a null (which
    +    // connectWallet would read as a false ownership mismatch, audit LOW).
    +    const rpc = {
    +      getLedgerEntries: vi.fn(async () => {
    +        throw new Error("503 upstream request timeout");
    +      }),
    +    };
    +    const { deps } = makeDeps({ rpc: rpc as never });
    +    const manager = new SignerManager(deps);
    +
    +    await expect(
    +      manager.getSigner(SignerKey.Secp256r1(base64url.encode(Buffer.alloc(16, 9))))
    +    ).rejects.toThrow("503 upstream");
       });
     });
    diff --git a/src/relayer.test.ts b/src/relayer.test.ts
    index d4e4ecb..a612178 100644
    --- a/src/relayer.test.ts
    +++ b/src/relayer.test.ts
    @@ -58,6 +58,30 @@ describe("RelayerClient.send", () => {
         }
       });
     
    +  it("reports a non-terminal (pending) status as a distinct non-success result", async () => {
    +    // A `skipWait` submit returns status "pending" with no hash yet. It must NOT
    +    // map to success:true (which a poll loop would treat as confirmed).
    +    const relayer = withChannels({
    +      submitSorobanTransaction: vi.fn(async () => ({
    +        transactionId: "tx-p",
    +        hash: "",
    +        status: "pending",
    +      })),
    +    });
    +
    +    const result = await relayer.send("FUNC", [], { skipWait: true });
    +    expect(result.success).toBe(false);
    +    if (!result.success) {
    +      expect(result.error).toBeInstanceOf(RelayerError);
    +      expect(result.error.code).toBe(PasskeyKitErrorCode.RELAYER_PENDING);
    +      expect(result.error.context).toMatchObject({
    +        pending: true,
    +        status: "pending",
    +        transactionId: "tx-p",
    +      });
    +    }
    +  });
    +
       it("never throws on a PluginClientError — maps it to a RelayerError", async () => {
         const relayer = withChannels({
           submitSorobanTransaction: vi.fn(async () => {
    diff --git a/src/relayer.ts b/src/relayer.ts
    index e255f92..391c943 100644
    --- a/src/relayer.ts
    +++ b/src/relayer.ts
    @@ -53,7 +53,15 @@ export interface RelayerSubmitOptions {
       fundRelayerId?: string;
     }
     
    -/** A relayer status string that indicates on-chain failure. */
    +/**
    + * Terminal-success statuses (ALLOWLIST). Only a status that positively confirms
    + * on-chain inclusion counts as success — anything else is failure or still
    + * pending. An allowlist (not a failure denylist) is deliberate: an unrecognized
    + * or non-terminal status must never be mistaken for a confirmed transaction.
    + */
    +const SUCCESS_STATUS = /confirm|success/i;
    +
    +/** Terminal-failure statuses. */
     const FAILURE_STATUS = /fail|error|revert|reject/i;
     
     export class RelayerClient {
    @@ -127,7 +135,17 @@ export class RelayerClient {
       }
     
       private toResult(res: ChannelsTransactionResponse): TransactionResult {
    -    if (res.status && FAILURE_STATUS.test(res.status)) {
    +    const status = res.status ?? "";
    +
    +    if (SUCCESS_STATUS.test(status)) {
    +      return {
    +        success: true,
    +        hash: res.hash ?? "",
    +        ...(res.transactionId ? { transactionId: res.transactionId } : {}),
    +      };
    +    }
    +
    +    if (FAILURE_STATUS.test(status)) {
           return failedTransaction(
             new RelayerError(
               `Relayer reported status "${res.status}"`,
    @@ -138,11 +156,19 @@ export class RelayerClient {
           );
         }
     
    -    return {
    -      success: true,
    -      hash: res.hash ?? "",
    -      ...(res.transactionId ? { transactionId: res.transactionId } : {}),
    -    };
    +    // Neither terminal-success nor terminal-failure: the transaction is still
    +    // pending (e.g. a `skipWait` submit not yet polled to confirmation). Surface
    +    // it as a distinct, NON-success result carrying RELAYER_PENDING — never as
    +    // `success: true` — so a poll loop keeps polling instead of treating an
    +    // unconfirmed (or later-reverted) transaction as done.
    +    return failedTransaction(
    +      new RelayerError(
    +        `Relayer status "${res.status ?? "unknown"}" is not terminal (pending)`,
    +        PasskeyKitErrorCode.RELAYER_PENDING,
    +        { status: res.status, transactionId: res.transactionId, pending: true }
    +      ),
    +      res.hash ?? undefined
    +    );
       }
     
       private mapError(err: unknown): TransactionFailure {
    diff --git a/src/rpc-data.ts b/src/rpc-data.ts
    new file mode 100644
    index 0000000..b555c1c
    --- /dev/null
    +++ b/src/rpc-data.ts
    @@ -0,0 +1,71 @@
    +/**
    + * Direct contract-data ledger reads that distinguish a genuine not-found from a
    + * transport failure.
    + *
    + * `Server.getContractData` collapses BOTH a real not-found AND any transport
    + * error (429/5xx/timeout, aborted fetch) into an identical `{ code: 404 }`
    + * throw, so a caller can't tell "the entry is gone" from "the RPC hiccuped".
    + * These helpers go one level lower: `getLedgerEntries` resolves with an empty
    + * `entries` array for a genuine not-found and only throws on an actual transport
    + * failure. That lets eviction detection and `getSigner`'s ownership check treat
    + * the two cases correctly instead of false-evicting live signers (audit H2) or
    + * reporting a valid passkey as "not a signer" on a flaky RPC.
    + *
    + * @packageDocumentation
    + */
    +
    +import { Address, xdr } from "@stellar/stellar-sdk";
    +import { Durability, type Server } from "@stellar/stellar-sdk/rpc";
    +
    +/** Build the contract-data `LedgerKey` for a (contract, key, durability) triple. */
    +export function contractDataLedgerKey(
    +  contractId: string,
    +  key: xdr.ScVal,
    +  durability: Durability
    +): xdr.LedgerKey {
    +  return xdr.LedgerKey.contractData(
    +    new xdr.LedgerKeyContractData({
    +      contract: Address.fromString(contractId).toScAddress(),
    +      key,
    +      durability:
    +        durability === Durability.Temporary
    +          ? xdr.ContractDataDurability.temporary()
    +          : xdr.ContractDataDurability.persistent(),
    +    })
    +  );
    +}
    +
    +/**
    + * Read a contract-data entry's stored ScVal, or `null` when the entry genuinely
    + * does not exist in that durability.
    + *
    + * Transport errors are NOT swallowed — they propagate so a caller never mistakes
    + * a flaky RPC for an absent entry.
    + */
    +export async function readContractData(
    +  rpc: Server,
    +  contractId: string,
    +  key: xdr.ScVal,
    +  durability: Durability
    +): Promise {
    +  const { entries } = await rpc.getLedgerEntries(
    +    contractDataLedgerKey(contractId, key, durability)
    +  );
    +  const entry = entries[0];
    +  return entry ? entry.val.contractData().val() : null;
    +}
    +
    +/**
    + * Whether a contract-data entry exists in the given durability.
    + *
    + * Returns `false` only on a genuine not-found; a transport error throws (so
    + * callers conclude eviction exclusively from an authoritative not-found).
    + */
    +export async function contractDataExists(
    +  rpc: Server,
    +  contractId: string,
    +  key: xdr.ScVal,
    +  durability: Durability
    +): Promise {
    +  return (await readContractData(rpc, contractId, key, durability)) !== null;
    +}
    diff --git a/src/server.ts b/src/server.ts
    index 09a2fdd..2648413 100644
    --- a/src/server.ts
    +++ b/src/server.ts
    @@ -18,12 +18,10 @@
     import {
       Operation,
       TransactionBuilder,
    -  xdr,
       type Transaction,
     } from "@stellar/stellar-sdk";
     import { Durability, Server } from "@stellar/stellar-sdk/rpc";
     import { AssembledTransaction } from "@stellar/stellar-sdk/contract";
    -import base64url from "./base64url.js";
     import { RelayerClient, type RelayerClientConfig, type RelayerSubmitOptions } from "./relayer.js";
     import {
       ConfigurationError,
    @@ -32,7 +30,10 @@ import {
       PasskeyKitErrorCode,
     } from "./errors.js";
     import { failedTransaction } from "./contract-errors.js";
    +import { contractDataExists } from "./rpc-data.js";
    +import { signerKeyToContractScVal } from "./indexer/codec.js";
     import { DEFAULT_INDEXER_TIMEOUT_MS } from "./constants.js";
    +import { SignerKey } from "./types.js";
     import type { IndexedSigner, TransactionResult } from "./types.js";
     
     // Re-export the relayer surface so `passkey-kit/server` is the single
    @@ -96,6 +97,18 @@ function toBuiltTransaction(
       return input;
     }
     
    +/** Map an indexer row's `{ kind, key }` to the SDK-side {@link SignerKey}. */
    +function indexedSignerToKey(signer: IndexedSigner): SignerKey {
    +  switch (signer.kind) {
    +    case "Ed25519":
    +      return SignerKey.Ed25519(signer.key);
    +    case "Policy":
    +      return SignerKey.Policy(signer.key);
    +    default:
    +      return SignerKey.Secp256r1(signer.key);
    +  }
    +}
    +
     /** Whether any invokeHostFunction op carries source-account auth. */
     function hasSourceAccountAuth(transaction: Transaction): boolean {
       for (const op of transaction.operations) {
    @@ -279,15 +292,20 @@ export class PasskeyServer {
     
         if (this.rpc) {
           for (const signer of signers) {
    -        if (signer.storage === "Temporary") {
    +        if (signer.storage === "Temporary" && !signer.evicted) {
               try {
    -            await this.rpc.getContractData(
    +            // Probe by the SignerKey ScVal the contract stores the entry under,
    +            // NOT the raw keyId bytes (audit H2). A genuine not-found means
    +            // evicted; a transport error is left as reported.
    +            const exists = await contractDataExists(
    +              this.rpc,
                   contractId,
    -              xdr.ScVal.scvBytes(base64url.toBuffer(signer.key)),
    +              signerKeyToContractScVal(indexedSignerToKey(signer)),
                   Durability.Temporary
                 );
    +            if (!exists) signer.evicted = true;
               } catch {
    -            signer.evicted = true;
    +            // Transport error (429/5xx/timeout): eviction undeterminable.
               }
             }
           }
    
    From b2c1fad19e8f9b11a0ad79d3475b91ae294fd9ef Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sun, 12 Jul 2026 00:31:06 -0400
    Subject: [PATCH 26/30] fix(F2): deploy fee-bump mismatch (SDK) + e2e harness
     busy-race hardening
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    F2 live testnet verification surfaced two issues:
    
    - SDK product bug (src/managers/submission-manager.ts): signDeploy left the
      assembled deploy fee at inclusion+resource. Deploys go through the relayer's
      fee-bump ({xdr}) path, and a fee-bumped Soroban inner tx must have
      fee == resourceFee (the fee-bump supplies inclusion) — so every
      relayer-sponsored wallet deploy failed with RelayerError 7002 "Transaction
      fee must be equal to the resource fee". Pin the inner fee to the resource fee
      before the deployer signs (surgical envelope edit; TransactionBuilder.cloneFrom
      drops SorobanTransactionData → txMalformed). Verified live: testnet deploys
      now confirm on-chain (e.g. 3d168e24…, 069c23df…); 141 SDK/worker tests green.
    
    - e2e harness (scripts/e2e/browser-full-e2e-audit.sh): action buttons are
      disabled while app.busy, and flows hold busy past their success log
      (createWallet through its trailing auto-fund; write layers through a trailing
      balance refresh), so the next layer clicked a still-disabled button (silent
      no-op → timeout). Add wait_enabled/wait_idle settle gates before the reconnect
      and signer-row clicks and inside expect_after.
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     scripts/e2e/browser-full-e2e-audit.sh | 39 +++++++++++++++++++++++++++
     src/managers/submission-manager.ts    | 35 +++++++++++++++++-------
     2 files changed, 65 insertions(+), 9 deletions(-)
    
    diff --git a/scripts/e2e/browser-full-e2e-audit.sh b/scripts/e2e/browser-full-e2e-audit.sh
    index c8e773b..2a5543e 100755
    --- a/scripts/e2e/browser-full-e2e-audit.sh
    +++ b/scripts/e2e/browser-full-e2e-audit.sh
    @@ -85,9 +85,42 @@ fill_tid() {
     }
     has_tid() { [[ "$(ab get count "[data-testid=\"$1\"]" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; }
     
    +# Wait until a control is present AND enabled. Flows set `app.busy` for their
    +# whole duration (disabling action buttons), and `createWallet` keeps busy set
    +# through its trailing auto-fund AFTER the "Wallet created" log — so a layer that
    +# acts on the "created" log alone can click a still-disabled button (a no-op).
    +wait_enabled() {
    +  local tid="$1" attempts="${2:-90}"
    +  for _ in $(seq 1 "$attempts"); do
    +    if [[ "$(ab eval "document.querySelector('[data-testid=\"$tid\"]')?.disabled === false" 2>/dev/null | tr -d '[:space:]"')" == "true" ]]; then
    +      return 0
    +    fi
    +    sleep 1
    +  done
    +  return 1
    +}
    +
    +# Best-effort "let the previous flow settle" gate. Every action button is
    +# `disabled={app.busy}`, and a flow can keep busy set for a moment AFTER its
    +# success log (e.g. a trailing balance refresh) — so the next layer, clicking
    +# immediately, would hit a still-disabled button (a silent no-op → timeout).
    +# Gates on the always-present `disconnect` button; a no-op while disconnected
    +# (button absent, e.g. the create layer). Never hard-fails — the action's own
    +# wait catches a genuine stall.
    +wait_idle() {
    +  for _ in $(seq 1 "${1:-90}"); do
    +    local s
    +    s="$(ab eval '(()=>{const b=document.querySelector("[data-testid=\"disconnect\"]");return b?(b.disabled?"busy":"idle"):"absent";})()' 2>/dev/null | tr -d '[:space:]"')"
    +    [[ "$s" == "idle" || "$s" == "absent" ]] && return 0
    +    sleep 1
    +  done
    +  return 0
    +}
    +
     # Run one write layer: capture the log count, run the click closure, wait.
     expect_after() {
       local success="$1" failure="$2" attempts="${3:-90}"; shift 3
    +  wait_idle
       local before; before="$(log_line_count)"
       "$@"
       case "$(wait_new_log "$before" "$success" "$failure" "$attempts"; echo $?)" in
    @@ -146,6 +179,9 @@ echo "  deploy tx: $CREATE_HASH"
     ########################################################################
     layer "reconnect" 21
     echo "Disconnect + reconnect via discoverable passkey"
    +# createWallet stays busy through its auto-fund; wait for that to settle so the
    +# disconnect button is actually enabled before we click it.
    +wait_enabled "disconnect" 90 || fail "disconnect never became enabled (create auto-fund still running?)"
     click_tid "disconnect"
     sleep 1
     has_tid "signin" || fail "signin control missing after disconnect"
    @@ -200,6 +236,7 @@ layer "update" 43
     echo "Update the Ed25519 signer (limits/store)"
     ED_ROW='[data-testid="signer-row"][data-kind="Ed25519"]'
     if [[ "$(ab get count "$ED_ROW" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; then
    +  wait_idle
       ab click "$ED_ROW [data-testid=\"signer-update\"]" >/dev/null 2>&1 || fail "update control missing"
       sleep 1
       before="$(log_line_count)"
    @@ -217,6 +254,7 @@ fi
     layer "per-signer-transfer" 44
     echo "Per-signer transfer via the Ed25519 signer row"
     if [[ "$(ab get count "$ED_ROW" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; then
    +  wait_idle
       before="$(log_line_count)"
       ab click "$ED_ROW [data-testid=\"signer-transfer\"]" >/dev/null 2>&1 || fail "per-signer transfer control missing"
       case "$(wait_new_log "$before" "Transfer via Ed25519 ✓" "Transfer via Ed25519 failed" 150; echo $?)" in
    @@ -232,6 +270,7 @@ fi
     layer "remove" 45
     echo "Remove the Ed25519 signer"
     if [[ "$(ab get count "$ED_ROW" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; then
    +  wait_idle
       before="$(log_line_count)"
       ab click "$ED_ROW [data-testid=\"signer-remove\"]" >/dev/null 2>&1 || fail "remove control missing"
       case "$(wait_new_log "$before" "Remove Ed25519 signer ✓" "Remove Ed25519 signer failed" 150; echo $?)" in
    diff --git a/src/managers/submission-manager.ts b/src/managers/submission-manager.ts
    index 8c70189..e983728 100644
    --- a/src/managers/submission-manager.ts
    +++ b/src/managers/submission-manager.ts
    @@ -10,8 +10,8 @@
      * @packageDocumentation
      */
     
    -import type { Keypair } from "@stellar/stellar-sdk";
    -import { basicNodeSigner } from "@stellar/stellar-sdk/contract";
    +import { TransactionBuilder } from "@stellar/stellar-sdk";
    +import type { Keypair, Transaction } from "@stellar/stellar-sdk";
     import type { AssembledTransaction } from "@stellar/stellar-sdk/contract";
     import type { Server } from "@stellar/stellar-sdk/rpc";
     import type { Client as PasskeyClient } from "passkey-kit-sdk";
    @@ -73,15 +73,32 @@ export class SubmissionManager {
       /**
        * Sign a deploy transaction with the deployer keypair (the fee source) and
        * return the signed transaction XDR.
    +   *
    +   * The deploy carries source-account auth, so it is submitted through the
    +   * relayer's fee-bump (`{ xdr }`) path. A fee-bumped Soroban inner transaction
    +   * must have `fee == resourceFee` — the fee-bump supplies the inclusion fee —
    +   * or the relayer rejects it with a fee mismatch ("Transaction fee must be
    +   * equal to the resource fee"). The assembled fee is `inclusion + resource`,
    +   * so pin it to the resource fee BEFORE the deployer signs (the signature
    +   * commits to the fee). `TransactionBuilder.cloneFrom` drops
    +   * `SorobanTransactionData` (→ txMalformed), so set the fee field surgically on
    +   * the envelope and rebuild.
        */
       async signDeploy(tx: AssembledTransaction): Promise {
    -    await tx.sign({
    -      signTransaction: basicNodeSigner(
    -        this.deps.deployerKeypair,
    -        this.deps.networkPassphrase
    -      ).signTransaction,
    -    });
    -    return tx.signed!.toXDR();
    +    if (!tx.built) {
    +      throw new Error("deploy transaction has not been built/simulated");
    +    }
    +    const envelope = tx.built.toEnvelope();
    +    const inner = envelope.v1().tx();
    +    const resourceFee = inner.ext().sorobanData().resourceFee().toString();
    +    inner.fee(Number(resourceFee));
    +
    +    const feeAdjusted = TransactionBuilder.fromXDR(
    +      envelope.toXDR("base64"),
    +      this.deps.networkPassphrase
    +    ) as Transaction;
    +    feeAdjusted.sign(this.deps.deployerKeypair);
    +    return feeAdjusted.toXDR();
       }
     
       /** Restore an archived footprint reported by simulation. */
    
    From d751a22853dc0d2682073265752dd4ac146e7416 Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sun, 12 Jul 2026 00:49:55 -0400
    Subject: [PATCH 27/30] docs(zephyr): correct Mercury deploy/query status (F2b
     research)
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Standard self-serve Zephyr flow is retired on Mercury's current backend:
    POST /zephyr/execute 404s even with a valid JWT (retroshade/query SQL works with
    the same token); /zephyr_upload + /zephyr_table_new gone; mercury-cli 0.2.1 dead;
    the --base CLI is source-built/non-public. Live self-serve model is Retroshades
    (/rest/retroshade/*). Mercury now hosts a Smart Account Indexer
    (/rest/smart-account-indexer, no auth) but it indexes OZ smart-account-kit, NOT
    passkey-kit (real wallet/signer → count:0). Documented the decision tree to get
    passkey-kit indexed (ask Mercury to add wasm 84924c53… to the hosted indexer, or
    self-host via the current CLI/dashboard) — all USER/Mercury-gated. Full probe
    evidence in Solo todo 967.
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     zephyr/README.md | 88 ++++++++++++++++++++++++++----------------------
     1 file changed, 48 insertions(+), 40 deletions(-)
    
    diff --git a/zephyr/README.md b/zephyr/README.md
    index 9ef6886..d4d66e7 100644
    --- a/zephyr/README.md
    +++ b/zephyr/README.md
    @@ -181,43 +181,51 @@ is exposed for parity with `get_signers_by_address`.
     
     ---
     
    -## Deploy (Mercury testnet) — tooling status (audit F2)
    -
    -**Re-verified against current upstream (2026-07): the published deploy tooling
    -is deprecated and does not work as documented.**
    -
    -- The published `mercury-cli` (crates.io `0.2.1`, the latest) hardcodes the base
    -  `https://api.mercurydata.app` and POSTs to `/zephyr_table_new` and
    -  `/zephyr_upload`. Both return **404** — upstream removed those endpoints (see
    -  rs-zephyr-toolkit commit *"flag the legacy Zephyr CLI — its server-side
    -  commands target removed backend endpoints; deprecate catchup"*, 2026-07-06).
    -  Note the CLI still prints `Successfully deployed Zephyr program.` after the
    -  404s — its exit code and message are **not** trustworthy.
    -- The current testnet backend has moved under `…/rest`
    -  (`GET https://api.mercurydata.app/rest/health` → `{"service":"up",…}`). The
    -  newer `mercury-cli` that targets it takes a `--base …/rest` flag but is **not
    -  published** to crates.io and is not in a public repo at the time of writing.
    -
    -**Historical command (for reference; currently 404s):**
    -
    -```sh
    -export MERCURY_JWT="…"    # from repo-root .env: MERCURY_TESTNET_JWT — NEVER print/commit
    -mercury-cli --jwt "$MERCURY_JWT" --local false --mainnet false deploy
    -```
    -
    -**Current deploy path:** the Mercury web dashboard (`test.mercurydata.app`,
    -"custom ingestion") or a `--base`-capable `mercury-cli` built from Mercury's
    -current source. This is an outward, account-touching step; the orchestrator
    -runs it at the verify/endgame gate. Tables are created from `zephyr.toml`
    -(program name `passkey-kit-indexer`); the wasm above is uploaded as-is.
    -
    -Secrets rule: the Mercury JWT comes from the repo-root `.env`
    -(`MERCURY_TESTNET_JWT` / `MERCURY_MAINNET_JWT`) via shell substitution only —
    -never printed, logged, or committed.
    -
    -### Data catchup / backfill
    -
    -Standard execution (new ledger closes) needs no subscription. Historical
    -backfill needs a contract-event subscription (via the dashboard) plus a
    -`mercury-cli catchup` scoped by contracts/topics; the old catchup flow was
    -deprecated on 2026-07-06. Backfill is optional and orthogonal to live indexing.
    +## Deploy (Mercury) — tooling status (audit F2 / F2b, re-verified 2026-07-12)
    +
    +**The published self-serve standard-Zephyr flow this program targets is RETIRED
    +on Mercury's current backend. `passkey_kit_indexer.wasm` builds clean but cannot
    +be deployed/queried via public tooling today.** Live read-only probes:
    +
    +- `POST …/zephyr/execute` (the serverless-function query path) → **404 even with
    +  a valid JWT** (the JWT is good: `POST …/rest/retroshade/query {"query":"SELECT 1"}`
    +  → `200 [{"?column?":1}]`). Standard-Zephyr `execute` is gone.
    +- Old deploy routes `/zephyr_upload`, `/zephyr_table_new` → **404**. Published
    +  `mercury-cli 0.2.1` (crates.io, latest) hardcodes these and is dead (it still
    +  prints `Successfully deployed` after the 404s — its exit code is NOT
    +  trustworthy).
    +- The current backend is under `…/rest` (testnet `https://testnet.mercurydata.app/rest`
    +  == `https://api.mercurydata.app/rest`; mainnet `https://mainnet.mercurydata.app/rest`).
    +  The live self-serve model is **Retroshades** (`POST /rest/retroshade/deploy`,
    +  query via `POST /rest/retroshade/query` SQL) — a different programming model
    +  than this program (`on_close` + `DatabaseDerive` tables + `env.conclude`
    +  serverless functions).
    +- The `--base`-capable `mercury-cli` (`mercury-cli --base …/rest deploy …`) is
    +  **source-built and not in any public repo** (the `MercuryTechnologies/mercury-cli`
    +  on GitHub is Mercury's unrelated *banking* CLI). Obtaining it needs Mercury.
    +
    +### Mercury now hosts a Smart Account Indexer (but not passkey-kit — yet)
    +
    +`GET {base}/rest/smart-account-indexer` is a free, no-auth, hosted reverse-lookup
    +service on both networks: `GET /api/lookup/:credentialId` (passkey keyId →
    +wallets), `GET /api/lookup/address/:address` (Ed25519/Policy signer → wallets),
    +`GET /api/contract/:contractId` (a wallet's signers). **It indexes OpenZeppelin
    +smart-account-kit, NOT passkey-kit** — a real passkey-kit wallet/signer returns
    +`count:0` (verified). passkey-kit's `signer_added/updated/removed/upgraded`
    +events are a different schema/wasm.
    +
    +### How to get passkey-kit indexed on Mercury (all paths are USER / Mercury-gated)
    +
    +1. **Preferred — ask Mercury to add passkey-kit to the hosted Smart Account
    +   Indexer** (same product they already run). Give them the v1 wasm hash
    +   `84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319` (+ legacy
    +   `e45c42b9…`). Then NO deploy — the SDK just queries the `/api/lookup/*` routes
    +   above. Contact: xyclooLabs Discord / Telegram.
    +2. **Self-host** via the current `--base` CLI (from Mercury) or the dashboard
    +   (`test.mercurydata.app/custom-ingestion`, browser login) — likely requires
    +   migrating this program to Retroshades (SQL query) and reconciling the SDK's
    +   `mercury.ts` accordingly.
    +
    +Secrets rule: Mercury JWTs come from the repo-root `.env` (`MERCURY_TESTNET_JWT`
    +/ `MERCURY_MAINNET_JWT`) via shell substitution only — never printed or
    +committed. Full research + probe evidence: Solo todo 967 comment.
    
    From 02a57b79090889b53ce4703fa5298f63ba82987a Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sun, 12 Jul 2026 00:56:27 -0400
    Subject: [PATCH 28/30] =?UTF-8?q?fix(sdk/indexer):=20F2b=20=E2=80=94=20Ste?=
     =?UTF-8?q?llar=20Indexer=20JSON-ScVal=20decode=20+=20mainnet-only;=20gate?=
     =?UTF-8?q?=20the=20dead=20Mercury=20endpoint?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    todo 967 (F2b indexer reconciliation). src/indexer/** only; no contract/hash change.
    
    STELLAR INDEXER (src/indexer/stellar-indexer.ts) — the live POST /v1/contract-data
    returns each entry's key/val as JSON ScVal (`{"vec":[{"symbol":..},{"bytes":..}]}`),
    NOT the base64-XDR the backend decoded (F2, todo 959 c2502).
    - Added jsonScValToXdr: JSON ScVal -> xdr.ScVal using the standard Stellar
      convention (symbol / bytes=hex / address=strkey / vec / map=[{key,val}] /
      u64,i64=string / u32,i32=number / bool / void), then reuse the contract-spec
      decoders (scValToSignerKey / decodeSignerVal). Unknown/malformed variants
      THROW — never a silent misdecode.
    - StellarIndexerEntry updated to the live field set (key/val JSON ScVal;
      id/contract_id/last_modified_ledger_seq/tx_meta_version/timestamp/created_at/
      updated_at optional); entriesToSigners groups by the raw JSON key.
    - Mainnet-only made honest (the indexer returns 0 entries on testnet): added
      STELLAR_INDEXER_NETWORK, a networkPassphrase config, a forNetwork() factory
      returning null off mainnet (discovery disabled, not "no signers"), and a
      constructor guard that throws on a non-mainnet passphrase — testnet no longer
      silently returns []. extractEntries still throws on unknown non-empty shapes.
    - Live validation needs a mainnet v1 wallet (endgame-gated); unit-tested against
      the documented shape now.
    
    MERCURY (src/indexer/mercury.ts) — the assumed POST /zephyr/execute route is
    RETIRED (404 even with a valid JWT; F2b todo 959 c2502 + services c2504). The
    replacement is a pending USER decision, NOT the SDK's to make:
      A. Mercury's hosted Smart Account Indexer (/rest/smart-account-indexer) — but
         it currently indexes OZ smart-account-kit events, not passkey-kit (real
         passkey wallets return count:0), so it needs Mercury to add passkey-kit's
         wasm coverage.
      B. Self-deployed zephyr program + Mercury's current (non-public) tooling.
    So the client is NOT re-pointed at either surface. Instead the backend is GATED:
    unless the new `zephyrExecuteConfirmed` opt-in is set, every query throws a clear
    "pending (todo 967)" IndexerError instead of silently 404-ing, and health()
    reports { ok: false }. Both options are documented in the module.
    
    Green: pnpm build (bindings + tsc + verify-esm) + npx vitest run = 159 tests /
    17 files (+18: JSON-ScVal decode, mainnet gate, Mercury pending-gate).
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     src/indexer/index.ts                |   2 +
     src/indexer/mercury.test.ts         |  28 ++++
     src/indexer/mercury.ts              |  64 ++++++++-
     src/indexer/stellar-indexer.test.ts | 162 ++++++++++++++++++-----
     src/indexer/stellar-indexer.ts      | 196 +++++++++++++++++++++++++---
     5 files changed, 401 insertions(+), 51 deletions(-)
    
    diff --git a/src/indexer/index.ts b/src/indexer/index.ts
    index fde6770..c0ad67c 100644
    --- a/src/indexer/index.ts
    +++ b/src/indexer/index.ts
    @@ -28,6 +28,8 @@ export {
       type StellarIndexerConfig,
       type StellarIndexerEntry,
       MAX_CONTRACTS_PER_QUERY,
    +  STELLAR_INDEXER_NETWORK,
    +  jsonScValToXdr,
     } from "./stellar-indexer.js";
     
     export {
    diff --git a/src/indexer/mercury.test.ts b/src/indexer/mercury.test.ts
    index 6b9b9f1..42111f2 100644
    --- a/src/indexer/mercury.test.ts
    +++ b/src/indexer/mercury.test.ts
    @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
     import { Keypair, xdr } from "@stellar/stellar-sdk";
     import base64url from "../base64url.js";
     import { SignerKey } from "../types.js";
    +import { IndexerError } from "../errors.js";
     import { SIGNER_VAL_UDT } from "../kit/auth-payload.js";
     import { deriveContractAddress } from "../utils.js";
     import { MercuryIndexer, type MercurySignerRow } from "./mercury.js";
    @@ -54,6 +55,7 @@ describe("MercuryIndexer.getSigners", () => {
           url: "https://mercury.test",
           projectName: "proj",
           jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
           now: () => 1000,
         });
         const signers = await indexer.getSigners("CWALLET");
    @@ -67,6 +69,27 @@ describe("MercuryIndexer.getSigners", () => {
       });
     });
     
    +describe("MercuryIndexer pending-endpoint gate (F2b, todo 967)", () => {
    +  // No zephyrExecuteConfirmed -> the /zephyr/execute route is retired (404), so
    +  // queries must fail loud rather than silently 404.
    +  function gated() {
    +    return new MercuryIndexer({ url: "https://mercury.test", projectName: "proj", jwt: "jwt" });
    +  }
    +
    +  it("throws a clear pending IndexerError from queries instead of 404-ing", async () => {
    +    await expect(gated().getSigners("CWALLET")).rejects.toBeInstanceOf(IndexerError);
    +    await expect(
    +      gated().findWallets(SignerKey.Secp256r1(base64url.encode(Buffer.alloc(16, 1))))
    +    ).rejects.toBeInstanceOf(IndexerError);
    +  });
    +
    +  it("reports health ok:false with the pending detail (no silent failure)", async () => {
    +    const h = await gated().health();
    +    expect(h.ok).toBe(false);
    +    expect(h.detail).toMatch(/pending|todo 967|retired/i);
    +  });
    +});
    +
     describe("MercuryIndexer eviction probe (audit H2)", () => {
       const WALLET = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
       const keyId = base64url.encode(Buffer.alloc(16, 5));
    @@ -93,6 +116,7 @@ describe("MercuryIndexer eviction probe (audit H2)", () => {
           url: "https://mercury.test",
           projectName: "proj",
           jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
           now: () => 1000,
           rpc: fakeRpc(getLedgerEntries),
         });
    @@ -116,6 +140,7 @@ describe("MercuryIndexer eviction probe (audit H2)", () => {
           url: "https://mercury.test",
           projectName: "proj",
           jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
           now: () => 1000,
           rpc: fakeRpc(vi.fn(async () => ({ entries: [] }))),
         });
    @@ -130,6 +155,7 @@ describe("MercuryIndexer eviction probe (audit H2)", () => {
           url: "https://mercury.test",
           projectName: "proj",
           jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
           now: () => 1000,
           rpc: fakeRpc(
             vi.fn(async () => {
    @@ -157,6 +183,7 @@ describe("MercuryIndexer.findWallets hardening", () => {
           url: "https://mercury.test",
           projectName: "proj",
           jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
           hardening: { networkPassphrase: TESTNET, deployerPublicKey: DEPLOYER },
         });
     
    @@ -171,6 +198,7 @@ describe("MercuryIndexer.findWallets hardening", () => {
           url: "https://mercury.test",
           projectName: "proj",
           jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
         });
         const wallets = await indexer.findWallets(
           SignerKey.Secp256r1(base64url.encode(Buffer.alloc(16, 8)))
    diff --git a/src/indexer/mercury.ts b/src/indexer/mercury.ts
    index c22da82..ca22947 100644
    --- a/src/indexer/mercury.ts
    +++ b/src/indexer/mercury.ts
    @@ -3,13 +3,37 @@
      *
      * Calls the deployed Zephyr program's serverless functions
      * (`get_signers_by_address`, `get_addresses_by_signer`) via
    - * `POST {url}/zephyr/execute`.
    - *
    - * C1 ALIGNMENT: the exact row/argument shapes are finalized by the reworked
    - * Zephyr program (todo C1). This maps the documented shapes and is reconciled in
    - * F2's live cross-backend check. Expiration is treated as a UNIX timestamp
    + * `POST {url}/zephyr/execute`. Expiration is treated as a UNIX timestamp
      * (seconds), per the reworked contract (#602).
      *
    + * ⚠️ RECONCILIATION NOTE — the query endpoint is STALE (F2b, todo 967).
    + * F2 (todo 959 c2502) confirmed `POST /zephyr/execute` returns **404** on the
    + * current Mercury `/rest` backend, and Mercury's current docs no longer document
    + * the Zephyr execute route at all — custom-program querying moved to
    + * "Retroshades" (`POST /rest/retroshade/query` with SQL, e.g.
    + * `SELECT * FROM retroshade.program__`), which requires the reworked
    + * `passkey-kit-indexer` program to be deployed (its deploy path is the blocked
    + * opus-services workstream).
    + *
    + * Separately, Mercury now ships a **public, no-auth Smart Account Indexer** for
    + * passkey wallets on BOTH networks:
    + *   `https://{testnet,mainnet}.mercurydata.app/rest/smart-account-indexer`
    + *     GET /api/contract/:contractId        -> signers (this backend's getSigners)
    + *     GET /api/lookup/:credentialId        -> wallets by passkey keyId (findWallets)
    + *     GET /api/lookup/address/:address     -> wallets by signer address
    + * That likely obviates the custom Zephyr deploy entirely. It is NOT wired here
    + * yet because (a) its `GET /api/contract/:contractId` signer response schema is
    + * not published, and (b) its data model (context rules; native/external/
    + * delegated signers; policies) may not map 1:1 onto this backend's v1
    + * `SignerKey`/`SignerVal`/`SignerLimits`/durability/status. Reconciling needs a
    + * captured live response + a model-mapping decision — coordinated with
    + * opus-services' deploy characterization on todo 967.
    + *
    + * Because A-vs-B is a pending USER decision, this backend is NOT re-pointed at
    + * either surface. Instead it is GATED: unless {@link MercuryIndexerConfig.zephyrExecuteConfirmed}
    + * is set, every query throws a clear "pending" error (todo 967) rather than
    + * firing at the dead `/zephyr/execute` route — honest failure, no silent 404.
    + *
      * @packageDocumentation
      */
     
    @@ -60,6 +84,22 @@ export interface MercuryIndexerConfig {
       hardening?: FindWalletsHardeningDeps;
       /** Clock source (seconds); injectable for tests. Defaults to `Date.now()`. */
       now?: () => number;
    +  /**
    +   * OPT-IN to the (currently non-functional) `POST /zephyr/execute` query path.
    +   *
    +   * The Mercury query integration is PENDING a strategy decision the USER owns
    +   * (todo 967): the SDK's assumed `/zephyr/execute` route is RETIRED — it 404s
    +   * even with a valid JWT (F2b, todo 959 c2502/2504). The two unresolved options:
    +   *   A. Mercury's hosted Smart Account Indexer REST surface
    +   *      (`/rest/smart-account-indexer/api/lookup/*`) — the same product
    +   *      smart-account-kit uses; would need Mercury to index passkey-kit's wasm.
    +   *   B. Keep this self-deployed zephyr program model and obtain the current
    +   *      Mercury deploy + query tooling.
    +   * Until that lands the backend is GATED: unless this is `true`, every query
    +   * throws a clear "pending" {@link IndexerError} instead of silently 404-ing.
    +   * Set it to `true` only once strategy B has a live endpoint at `url`.
    +   */
    +  zephyrExecuteConfirmed?: boolean;
     }
     
     export class MercuryIndexer implements SignerIndexer {
    @@ -77,6 +117,20 @@ export class MercuryIndexer implements SignerIndexer {
       }
     
       private async execute(fname: string, args: unknown): Promise {
    +    // Gate: the `/zephyr/execute` route is retired (404) and the replacement is
    +    // a pending user decision (todo 967). Fail loud + actionable rather than
    +    // firing a request that just 404s. `health()` catches this → `{ ok: false }`.
    +    if (!this.config.zephyrExecuteConfirmed) {
    +      throw new IndexerError(
    +        "Mercury query integration is pending a strategy decision (todo 967): the assumed " +
    +          "POST /zephyr/execute route is retired (404 with a valid JWT). Resolve strategy A " +
    +          "(hosted Smart Account Indexer /rest/smart-account-indexer) vs B (self-deployed zephyr " +
    +          "program + current tooling), then wire the chosen endpoint. Set " +
    +          "zephyrExecuteConfirmed:true to force the legacy path once B has a live endpoint.",
    +        PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED
    +      );
    +    }
    +
         const controller = new AbortController();
         const timeout = setTimeout(
           () => controller.abort(),
    diff --git a/src/indexer/stellar-indexer.test.ts b/src/indexer/stellar-indexer.test.ts
    index d23cbe3..dfd2d48 100644
    --- a/src/indexer/stellar-indexer.test.ts
    +++ b/src/indexer/stellar-indexer.test.ts
    @@ -1,30 +1,16 @@
     import { afterEach, describe, expect, it, vi } from "vitest";
    -import { xdr } from "@stellar/stellar-sdk";
    +import { Address, Keypair, Networks, nativeToScVal, xdr } from "@stellar/stellar-sdk";
     import base64url from "../base64url.js";
     import { SignerKey } from "../types.js";
     import { IndexerError } from "../errors.js";
    -import { SIGNER_VAL_UDT } from "../kit/auth-payload.js";
     import {
       StellarIndexerBackend,
    +  jsonScValToXdr,
       type StellarIndexerEntry,
     } from "./stellar-indexer.js";
    -import { signerKeyToContractScVal, walletSpec } from "./codec.js";
    +import { scValToSignerKey, signerKeyToIndexerJson } from "./codec.js";
     
    -function keyXdr(keyId: string): string {
    -  return signerKeyToContractScVal(SignerKey.Secp256r1(keyId)).toXDR("base64");
    -}
    -
    -function valXdr(expiration?: number): string {
    -  return walletSpec()
    -    .nativeToScVal(
    -      {
    -        tag: "Secp256r1",
    -        values: [Buffer.alloc(65, 4), [expiration], [undefined]],
    -      },
    -      SIGNER_VAL_UDT
    -    )
    -    .toXDR("base64");
    -}
    +const CONTRACT = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
     
     function backend(): StellarIndexerBackend {
       return new StellarIndexerBackend({
    @@ -34,13 +20,89 @@ function backend(): StellarIndexerBackend {
       });
     }
     
    -describe("StellarIndexerBackend.entriesToSigners", () => {
    +/** Documented JSON-ScVal `val` for a Secp256r1 SignerVal (65-byte pubkey). */
    +function secpValJson(expiration?: number, limits?: unknown): unknown {
    +  const exp = expiration == null ? { void: null } : { u64: String(expiration) };
    +  return {
    +    vec: [
    +      { symbol: "Secp256r1" },
    +      { bytes: Buffer.alloc(65, 4).toString("hex") },
    +      { vec: [exp] }, // SignerExpiration = [Option]
    +      { vec: [limits ?? { void: null }] }, // SignerLimits = [Option]
    +    ],
    +  };
    +}
    +
    +describe("jsonScValToXdr (Stellar Indexer wire decode)", () => {
    +  const cases: Array<[string, unknown, xdr.ScVal]> = [
    +    ["symbol", { symbol: "Secp256r1" }, xdr.ScVal.scvSymbol("Secp256r1")],
    +    ["bytes (hex)", { bytes: "deadbeef" }, xdr.ScVal.scvBytes(Buffer.from("deadbeef", "hex"))],
    +    ["void", { void: null }, xdr.ScVal.scvVoid()],
    +    ["bool", { bool: true }, xdr.ScVal.scvBool(true)],
    +    ["u32", { u32: 42 }, xdr.ScVal.scvU32(42)],
    +    ["u64 (string)", { u64: "1786422535" }, nativeToScVal(1786422535n, { type: "u64" })],
    +    ["address", { address: CONTRACT }, Address.fromString(CONTRACT).toScVal()],
    +  ];
    +
    +  it.each(cases)("decodes %s to the same ScVal", (_name, json, expected) => {
    +    expect(jsonScValToXdr(json).toXDR("base64")).toBe(expected.toXDR("base64"));
    +  });
    +
    +  it("treats a bare null as void", () => {
    +    expect(jsonScValToXdr(null).toXDR("base64")).toBe(
    +      xdr.ScVal.scvVoid().toXDR("base64")
    +    );
    +  });
    +
    +  it("decodes a nested vec", () => {
    +    const json = { vec: [{ symbol: "Secp256r1" }, { bytes: "aabb" }] };
    +    const expected = xdr.ScVal.scvVec([
    +      xdr.ScVal.scvSymbol("Secp256r1"),
    +      xdr.ScVal.scvBytes(Buffer.from("aabb", "hex")),
    +    ]);
    +    expect(jsonScValToXdr(json).toXDR("base64")).toBe(expected.toXDR("base64"));
    +  });
    +
    +  it("decodes a map ([{key,val}])", () => {
    +    const json = { map: [{ key: { symbol: "a" }, val: { u32: 1 } }] };
    +    const expected = xdr.ScVal.scvMap([
    +      new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol("a"), val: xdr.ScVal.scvU32(1) }),
    +    ]);
    +    expect(jsonScValToXdr(json).toXDR("base64")).toBe(expected.toXDR("base64"));
    +  });
    +
    +  it("throws on an unknown or malformed variant (no silent misdecode)", () => {
    +    expect(() => jsonScValToXdr({ wat: 1 })).toThrow(IndexerError);
    +    expect(() => jsonScValToXdr({ symbol: "a", extra: 1 })).toThrow(IndexerError);
    +    expect(() => jsonScValToXdr("nope")).toThrow(IndexerError);
    +  });
    +
    +  it("round-trips a real SignerKey through the SDK's own documented-JSON encoder", () => {
    +    // signerKeyToIndexerJson emits the documented JSON-ScVal key predicate; the
    +    // live response uses the same convention, so decode must invert it.
    +    const keyId = base64url.encode(Buffer.alloc(16, 0xab));
    +    const decoded = scValToSignerKey(
    +      jsonScValToXdr(signerKeyToIndexerJson(SignerKey.Secp256r1(keyId)))
    +    );
    +    expect(decoded.key).toBe("Secp256r1");
    +    expect(decoded.value).toBe(keyId);
    +
    +    const g = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)).publicKey();
    +    const ed = scValToSignerKey(
    +      jsonScValToXdr(signerKeyToIndexerJson(SignerKey.Ed25519(g)))
    +    );
    +    expect(ed.key).toBe("Ed25519");
    +    expect(ed.value).toBe(g);
    +  });
    +});
    +
    +describe("StellarIndexerBackend.entriesToSigners (JSON ScVal entries)", () => {
       it("prefers a live entry over a tombstoned twin (durability flip)", () => {
         const keyId = base64url.encode(Buffer.alloc(16, 1));
    -    const key = keyXdr(keyId);
    +    const key = signerKeyToIndexerJson(SignerKey.Secp256r1(keyId));
         const entries: StellarIndexerEntry[] = [
    -      { key, val: valXdr(), durability: "temporary", deleted_at: "2026-07-11" },
    -      { key, val: valXdr(), durability: "persistent", deleted_at: null },
    +      { key, val: secpValJson(), durability: "temporary", deleted_at: "2026-07-11" },
    +      { key, val: secpValJson(), durability: "persistent", deleted_at: null },
         ];
     
         const signers = backend().entriesToSigners(entries);
    @@ -48,30 +110,72 @@ describe("StellarIndexerBackend.entriesToSigners", () => {
         expect(signers[0]!.status).toBe("live");
         expect(signers[0]!.storage).toBe("persistent");
         expect(signers[0]!.key.value).toBe(keyId);
    +    expect(signers[0]!.publicKey).toHaveLength(65);
       });
     
       it("marks a key removed when every entry is tombstoned", () => {
         const keyId = base64url.encode(Buffer.alloc(16, 2));
    -    const key = keyXdr(keyId);
         const entries: StellarIndexerEntry[] = [
    -      { key, val: valXdr(), durability: "persistent", deleted_at: "2026-07-11" },
    +      {
    +        key: signerKeyToIndexerJson(SignerKey.Secp256r1(keyId)),
    +        val: secpValJson(),
    +        durability: "persistent",
    +        deleted_at: "2026-07-11",
    +      },
         ];
    -    const signers = backend().entriesToSigners(entries);
    -    expect(signers[0]!.status).toBe("removed");
    +    expect(backend().entriesToSigners(entries)[0]!.status).toBe("removed");
       });
     
       it("marks a live-but-expired entry expired", () => {
         const keyId = base64url.encode(Buffer.alloc(16, 3));
         const entries: StellarIndexerEntry[] = [
           {
    -        key: keyXdr(keyId),
    -        val: valXdr(500), // expiration 500 < now 1000
    +        key: signerKeyToIndexerJson(SignerKey.Secp256r1(keyId)),
    +        val: secpValJson(500), // expiration 500 < now 1000
             durability: "persistent",
             deleted_at: null,
           },
         ];
         expect(backend().entriesToSigners(entries)[0]!.status).toBe("expired");
       });
    +
    +  it("decodes an Ed25519 signer (no stored public key)", () => {
    +    const g = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)).publicKey();
    +    const entries: StellarIndexerEntry[] = [
    +      {
    +        key: signerKeyToIndexerJson(SignerKey.Ed25519(g)),
    +        val: { vec: [{ symbol: "Ed25519" }, { vec: [{ void: null }] }, { vec: [{ void: null }] }] },
    +        durability: "persistent",
    +        deleted_at: null,
    +      },
    +    ];
    +    const signer = backend().entriesToSigners(entries)[0]!;
    +    expect(signer.key.key).toBe("Ed25519");
    +    expect(signer.key.value).toBe(g);
    +    expect(signer.status).toBe("live");
    +    expect(signer.publicKey).toBeUndefined();
    +  });
    +});
    +
    +describe("StellarIndexerBackend mainnet-only network awareness", () => {
    +  const cfg = { url: "https://indexer.test", accessToken: "token" };
    +
    +  it("forNetwork returns null off mainnet (discovery disabled, not empty signers)", () => {
    +    expect(StellarIndexerBackend.forNetwork(cfg, Networks.TESTNET)).toBeNull();
    +    expect(StellarIndexerBackend.forNetwork(cfg, Networks.FUTURENET)).toBeNull();
    +  });
    +
    +  it("forNetwork returns a backend on mainnet", () => {
    +    expect(StellarIndexerBackend.forNetwork(cfg, Networks.PUBLIC)).toBeInstanceOf(
    +      StellarIndexerBackend
    +    );
    +  });
    +
    +  it("constructing pinned to a non-mainnet passphrase throws (no silent [])", () => {
    +    expect(
    +      () => new StellarIndexerBackend({ ...cfg, networkPassphrase: Networks.TESTNET })
    +    ).toThrow(IndexerError);
    +  });
     });
     
     describe("StellarIndexerBackend.findWallets", () => {
    @@ -103,8 +207,6 @@ describe("StellarIndexerBackend response-shape handling (audit LOW)", () => {
       }
     
       it("throws IndexerError on an unrecognized non-empty response shape", async () => {
    -    // A silent [] here would report a real wallet as signer-less after burning
    -    // the retry budget.
         stubResponse({ results: [{ key: "x" }] });
         await expect(backend().getSigners(WALLET)).rejects.toBeInstanceOf(
           IndexerError
    diff --git a/src/indexer/stellar-indexer.ts b/src/indexer/stellar-indexer.ts
    index 6833e01..30ec861 100644
    --- a/src/indexer/stellar-indexer.ts
    +++ b/src/indexer/stellar-indexer.ts
    @@ -7,15 +7,30 @@
      * wallet address from the keyId, then confirm the signer entry exists there.
      * Removed signers appear as tombstoned (`deleted_at`) entries.
      *
    - * WIRE NOTE: the exact request/response field names, key/val encoding (base64
    - * XDR vs JSON ScVal), and the `deleted_at` shape are confirmed live against the
    - * beta API in F2. This codes to the documented shape; adjust the small
    - * `parseEntry`/`buildBody` seams if F2 shows otherwise.
    + * WIRE FORMAT (F2-confirmed, todo 959 c2502 / todo 967):
    + * - The live API returns each entry's `key`/`val` as **JSON ScVal**, e.g.
    + *   `{"vec":[{"symbol":"Secp256r1"},{"bytes":""}]}` — NOT base64 XDR. We
    + *   convert JSON ScVal -> `xdr.ScVal` ({@link jsonScValToXdr}) and reuse the
    + *   contract-spec decoders (`scValToSignerKey`/`decodeSignerVal`). The JSON
    + *   convention is the standard Stellar one: `symbol`, `bytes` (hex), `address`
    + *   (strkey), `vec` (array), `map` (`[{key,val}]`), `u64`/`i64` (string),
    + *   `u32`/`i32` (number), `bool`, `string`, `void` (None).
    + * - Entry fields: `id, contract_id, durability, key, val,
    + *   last_modified_ledger_seq, tx_meta_version, timestamp, created_at,
    + *   updated_at, deleted_at`.
    + *
    + * NETWORK: **the Stellar Indexer indexes MAINNET only** — a testnet contract
    + * returns HTTP 200 with zero entries (F2). To keep "unsupported network" from
    + * masquerading as "wallet has no signers", the backend is network-aware: use
    + * {@link StellarIndexerBackend.forNetwork} (returns `null` off mainnet), and a
    + * config pinned to a non-mainnet passphrase throws at construction rather than
    + * silently returning `[]`. Live validation therefore needs a mainnet v1 wallet
    + * (endgame-gated); until then this is unit-tested against the documented shape.
      *
      * @packageDocumentation
      */
     
    -import { xdr } from "@stellar/stellar-sdk";
    +import { Address, Networks, nativeToScVal, xdr } from "@stellar/stellar-sdk";
     import { SignerKey } from "../types.js";
     import { IndexerError, PasskeyKitErrorCode } from "../errors.js";
     import { DEFAULT_INDEXER_TIMEOUT_MS } from "../constants.js";
    @@ -38,12 +53,28 @@ import {
     /** Max contract ids per contract-data request (API cap). */
     export const MAX_CONTRACTS_PER_QUERY = 25;
     
    -/** A raw contract-data entry (documented shape; F2-confirmed). */
    +/** The only network the Stellar Indexer indexes (mainnet). */
    +export const STELLAR_INDEXER_NETWORK = Networks.PUBLIC;
    +
    +/**
    + * A raw contract-data entry (F2-confirmed live shape). `key`/`val` are JSON
    + * ScVal objects (see {@link jsonScValToXdr}), not base64-XDR strings; the extra
    + * fields are carried through untyped since only these four drive decoding.
    + */
     export interface StellarIndexerEntry {
    -  key: string; // base64 XDR of the SignerKey ScVal
    -  val: string; // base64 XDR of the SignerVal ScVal
    +  /** JSON ScVal of the SignerKey. */
    +  key: unknown;
    +  /** JSON ScVal of the SignerVal. */
    +  val: unknown;
       durability: "persistent" | "temporary";
       deleted_at: string | null;
    +  id?: string;
    +  contract_id?: string;
    +  last_modified_ledger_seq?: number;
    +  tx_meta_version?: string | number;
    +  timestamp?: string;
    +  created_at?: string;
    +  updated_at?: string;
     }
     
     export interface StellarIndexerConfig {
    @@ -51,6 +82,13 @@ export interface StellarIndexerConfig {
       url: string;
       /** Bearer access token (server-side secret). */
       accessToken: string;
    +  /**
    +   * Network passphrase this backend serves. The Stellar Indexer indexes mainnet
    +   * only; a non-mainnet passphrase throws at construction (prefer
    +   * {@link StellarIndexerBackend.forNetwork} to get a `null` off mainnet). When
    +   * omitted, mainnet is assumed.
    +   */
    +  networkPassphrase?: string;
       /** Deps enabling derive-then-confirm reverse lookup. */
       hardening?: FindWalletsHardeningDeps;
       /** Clock source (seconds); injectable for tests. */
    @@ -65,6 +103,30 @@ export class StellarIndexerBackend implements SignerIndexer {
             PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED
           );
         }
    +    if (
    +      config.networkPassphrase &&
    +      config.networkPassphrase !== STELLAR_INDEXER_NETWORK
    +    ) {
    +      throw new IndexerError(
    +        `Stellar Indexer indexes mainnet only; refusing a backend pinned to "${config.networkPassphrase}". ` +
    +          `Use StellarIndexerBackend.forNetwork(...) to get null off mainnet.`,
    +        PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED
    +      );
    +    }
    +  }
    +
    +  /**
    +   * Network-aware factory: the backend for mainnet, or `null` for any other
    +   * network (the Stellar Indexer doesn't index testnet, so "discovery disabled"
    +   * is the honest answer — callers treat `null` as no-indexer rather than an
    +   * empty signer set).
    +   */
    +  static forNetwork(
    +    config: Omit,
    +    networkPassphrase: string
    +  ): StellarIndexerBackend | null {
    +    if (networkPassphrase !== STELLAR_INDEXER_NETWORK) return null;
    +    return new StellarIndexerBackend({ ...config, networkPassphrase });
       }
     
       private nowSeconds(): number {
    @@ -127,21 +189,20 @@ export class StellarIndexerBackend implements SignerIndexer {
       entriesToSigners(entries: StellarIndexerEntry[]): WalletSigner[] {
         const groups = new Map();
         for (const entry of entries) {
    -      const list = groups.get(entry.key) ?? [];
    +      // Group on the raw JSON ScVal of the key (identical signer keys serialize
    +      // identically); decode once per group below.
    +      const groupKey = JSON.stringify(entry.key);
    +      const list = groups.get(groupKey) ?? [];
           list.push(entry);
    -      groups.set(entry.key, list);
    +      groups.set(groupKey, list);
         }
     
         const signers: WalletSigner[] = [];
         for (const [, group] of groups) {
           const live = group.find((e) => e.deleted_at == null);
           const chosen = live ?? group[0]!;
    -      const key = scValToSignerKey(
    -        xdr.ScVal.fromXDR(Buffer.from(chosen.key, "base64"))
    -      );
    -      const decoded = decodeSignerVal(
    -        xdr.ScVal.fromXDR(Buffer.from(chosen.val, "base64"))
    -      );
    +      const key = scValToSignerKey(jsonScValToXdr(chosen.key));
    +      const decoded = decodeSignerVal(jsonScValToXdr(chosen.val));
           const storage: SignerStorageClass = chosen.durability;
           const status = live
             ? deriveStatus({
    @@ -224,3 +285,106 @@ function extractEntries(json: unknown): StellarIndexerEntry[] {
         PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
       );
     }
    +
    +/**
    + * Convert a Stellar JSON-ScVal object into an `xdr.ScVal`.
    + *
    + * The Stellar Indexer serializes ledger-entry `key`/`val` as JSON ScVal — a
    + * single-key object per value (`{"vec":[...]}`, `{"symbol":"…"}`, …). This is
    + * the inverse of that serialization for the variants a smart-wallet
    + * SignerKey/SignerVal can contain, letting the contract-spec decoders take over.
    + *
    + * Unknown variant keys THROW (never silently misdecode): an unexpected wire
    + * shape must surface as an error, not a wrong signer.
    + */
    +export function jsonScValToXdr(json: unknown): xdr.ScVal {
    +  // Some encoders represent `void`/None as a bare null.
    +  if (json === null || json === undefined) return xdr.ScVal.scvVoid();
    +  if (typeof json !== "object") {
    +    throw new IndexerError(
    +      `Unrecognized JSON ScVal (not an object): ${JSON.stringify(json)}`,
    +      PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +    );
    +  }
    +
    +  const obj = json as Record;
    +  const keys = Object.keys(obj);
    +  if (keys.length !== 1) {
    +    throw new IndexerError(
    +      `Unrecognized JSON ScVal (expected exactly one type key): ${JSON.stringify(keys)}`,
    +      PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +    );
    +  }
    +  const tag = keys[0]!;
    +  const v = obj[tag];
    +
    +  switch (tag) {
    +    case "void":
    +      return xdr.ScVal.scvVoid();
    +    case "bool":
    +      return xdr.ScVal.scvBool(Boolean(v));
    +    case "u32":
    +      return xdr.ScVal.scvU32(Number(v));
    +    case "i32":
    +      return xdr.ScVal.scvI32(Number(v));
    +    case "u64":
    +      return nativeToScVal(BigInt(v as string | number), { type: "u64" });
    +    case "i64":
    +      return nativeToScVal(BigInt(v as string | number), { type: "i64" });
    +    case "timepoint":
    +      return nativeToScVal(BigInt(v as string | number), { type: "timepoint" });
    +    case "duration":
    +      return nativeToScVal(BigInt(v as string | number), { type: "duration" });
    +    case "u128":
    +      return nativeToScVal(bigIntOfParts(v), { type: "u128" });
    +    case "i128":
    +      return nativeToScVal(bigIntOfParts(v), { type: "i128" });
    +    case "u256":
    +      return nativeToScVal(bigIntOfParts(v), { type: "u256" });
    +    case "i256":
    +      return nativeToScVal(bigIntOfParts(v), { type: "i256" });
    +    case "bytes":
    +      return xdr.ScVal.scvBytes(Buffer.from(String(v), "hex"));
    +    case "string":
    +      return xdr.ScVal.scvString(String(v));
    +    case "symbol":
    +      return xdr.ScVal.scvSymbol(String(v));
    +    case "address":
    +      return Address.fromString(String(v)).toScVal();
    +    case "vec":
    +      return xdr.ScVal.scvVec((v as unknown[]).map(jsonScValToXdr));
    +    case "map":
    +      return xdr.ScVal.scvMap(
    +        (v as Array<{ key: unknown; val: unknown }>).map(
    +          (e) =>
    +            new xdr.ScMapEntry({
    +              key: jsonScValToXdr(e.key),
    +              val: jsonScValToXdr(e.val),
    +            })
    +        )
    +      );
    +    default:
    +      throw new IndexerError(
    +        `Unsupported JSON ScVal type "${tag}"`,
    +        PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +      );
    +  }
    +}
    +
    +/**
    + * Coerce a large-integer JSON ScVal value to a bigint, accepting either a
    + * decimal string/number or a `{ hi, lo }` multi-part object (the exact 128/256
    + * wire shape is undocumented and unused by SignerVal — u64 covers expiration —
    + * so this stays tolerant).
    + */
    +function bigIntOfParts(v: unknown): bigint {
    +  if (typeof v === "string" || typeof v === "number") return BigInt(v);
    +  if (v && typeof v === "object" && "hi" in v && "lo" in v) {
    +    const { hi, lo } = v as { hi: string | number; lo: string | number };
    +    return (BigInt(hi) << 64n) | BigInt(lo);
    +  }
    +  throw new IndexerError(
    +    `Unrecognized large-integer JSON ScVal value: ${JSON.stringify(v)}`,
    +    PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +  );
    +}
    
    From c6e46dab3ac123fabb5ecf1e80e9a043c86ae526 Mon Sep 17 00:00:00 2001
    From: kalepail 
    Date: Sun, 12 Jul 2026 03:15:17 -0400
    Subject: [PATCH 29/30] =?UTF-8?q?docs(E1):=20v1=20overhaul=20docs=20?=
     =?UTF-8?q?=E2=80=94=20README=20rewrite=20+=20CHANGELOG=20+=20migration=20?=
     =?UTF-8?q?+=20releasing?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Rewrite the docs suite against the FINAL verified v1 API (every table/sample
    checked against src/ and the contract interface — no aspirational docs).
    
    - README.md: rewritten. Config tables (Option|Type|Required|Description) for
      PasskeyKit/PasskeyServer; method tables inlining the wrapped contract fn;
      Quick Start before the API reference; client (PasskeyKit) vs server
      (PasskeyServer) split; dist/exports packaging + three entrypoints; typed
      Signer pipeline; typed errors + discriminated TransactionResult + renumbered
      contract error table (100-129); storage adapters; relayer-proxy usage;
      indexer backends with the pending-decision framing; caveats/footguns
      (min-signer brick, temp sole-signer eviction, deploy front-running, WebAuthn
      UP); deterministic derivation. Killed the passkey-factory-sdk ghost, the
      Protocol-21 framing, the make deploy/init + SMART_WALLET_FACTORY note, the
      1.3.x relayer link, and the raw-TS shipping note.
    - CHANGELOG.md (new): SAK discipline (per-component versions, Compatibility
      callouts). Version is a placeholder (0.13.0 vs 1.0.0, decided at publish).
    - docs/migration-v1.md (new): Before/After, Removed exports, Behavior-changes
      table, and an explicit A/B GAP ANALYSIS (incl. the deferred live Mercury
      discovery regression).
    - docs/releasing.md (new): dependency-ordered publish (bindings -> SDK),
      verify:bindings gate, authed --dry-run, OTP step; folds the load-bearing
      cheatsheet commands.
    - Cruft: fold + delete cheatsheet.txt; move PROPOSAL.md -> docs/history/ with a
      historical banner; regenerate .cursorrules against the final stack.
    
    Docs only — no source/test changes; green baseline (tsc --noEmit) unaffected.
    
    Co-Authored-By: Claude Opus 4.8 
    ---
     .cursorrules                            | 239 +++------
     CHANGELOG.md                            |  80 +++
     README.md                               | 635 +++++++++++++++---------
     cheatsheet.txt                          |  18 -
     PROPOSAL.md => docs/history/PROPOSAL.md |  12 +
     docs/migration-v1.md                    | 227 +++++++++
     docs/releasing.md                       | 118 +++++
     7 files changed, 914 insertions(+), 415 deletions(-)
     create mode 100644 CHANGELOG.md
     delete mode 100644 cheatsheet.txt
     rename PROPOSAL.md => docs/history/PROPOSAL.md (97%)
     create mode 100644 docs/migration-v1.md
     create mode 100644 docs/releasing.md
    
    diff --git a/.cursorrules b/.cursorrules
    index abd00e0..563d23a 100644
    --- a/.cursorrules
    +++ b/.cursorrules
    @@ -1,170 +1,69 @@
    -# Passkey Kit – Cursor Workspace Rules (v2025-06-12)
    -
    -These rules provide canonical guidance to AI assistants and contributors interacting with this repository.  They supersede any previous `.cursorrules` file.
    -Their intent is to stay *future-proof*: they encode principles that should hold even as tooling evolves.  When in doubt, prefer the spirit of the rule over its letter.
    -
    ----
    -
    -## 1  Repository Topology (overview)
    -
    -•  `src/` – Core **passkey-kit** TypeScript library (published to npm).
    -•  `packages/passkey-kit-sdk/` – Stand-alone TS SDK used by the main package.
    -•  `packages/sac-sdk/` – SAC (smart-account contract) SDK.
    -•  `contracts/` – Soroban smart contracts written in Rust.
    -•  `zephyr/` – Deterministic Zephyr program exposing HTTP endpoints.
    -•  `demo/` – Svelte demo showcasing end-to-end usage.
    -
    -This repo is a **pnpm workspace**; always use `pnpm` (≥ 8) for Node tasks.
    -
    ----
    -
    -## 2  Language & Toolchain Baselines
    -
    -| Domain | Minimum Supported | Notes |
    -|--------|-------------------|-------|
    -| Node.js | 20 LTS | ESM by default, top-level `await` allowed |
    -| TypeScript | 5.4 | `strict`, `moduleResolution: node16` |
    -| Rust (contracts) | 1.79 | Follow the current stable Soroban toolchain |
    -| Soroban SDK | 22.x | Keep minor versions in lock-step across contracts |
    -| Bun (tests) | 1.x | Used for ultrafast unit tests (`bun test`) |
    -| pnpm | 8.x | Managed via **Corepack**; lockfile v5 enforced |
    -
    -Upgrade the baselines on every **major** release of the kit.
    -
    ----
    -
    -## 3  General Coding Principles
    -
    -1. **Functional & immutable** – avoid shared mutable state; favour pure functions.
    -2. **Fail loudly, fail early** – surface rich, actionable error messages.
    -3. **Minimal dependencies** – prefer platform APIs or internal utils; justify every new dep.
    -4. **Typed first** – all public surfaces must be *fully* typed; export `.d.ts` files.
    -5. **Async-first** – use `async/await`; never chain raw `Promise` callbacks.
    -6. **Workspace integrity** – after changes run `pnpm run build` at the repo root to guarantee type-safety across packages.
    -
    ----
    -
    -## 4  JavaScript / TypeScript Specifics
    -
    -• Import Stellar via `@stellar/stellar-sdk/minimal` unless full bundle is strictly required.
    -• Base-64 URL encode/decode with `base64url`; avoid `Buffer.from(x, 'base64')`.
    -• Prefer **named exports**; avoid `export default` in new code.
    -• File extensions: `.ts` / `.tsx` (React) / `.svelte` (demo) only.
    -• Module import grouping: external → workspace → relative → style imports; blank line between groups.
    -• Leverage modern ECMAScript (`??`, `?.`, `Array#at`, `Intl`) – but avoid stage-3 proposals unless polyfilled.
    -• Publish ESM-first: every package must declare `"type": "module"` and expose an `exports` map with `import`, `require` (fallback), and `types` conditions.
    -• Mark packages `"sideEffects": false` when safe to enable aggressive tree-shaking.
    -• Ship first-party `.d.ts` bundles (either via `tsc --emitDeclarationOnly` or `api-extractor`).  Do **not** rely on DefinitelyTyped for kit packages.
    -• Top-level entrypoints should be 100 % statically analyzable – avoid dynamic `require()` or `eval()`.
    -
    ----
    -
    -## 5  Rust / Soroban Contract Rules
    -
    -1. Contracts live in `contracts/*/src`.
    -2. `#![no_std]` mandatory; rely on `soroban-sdk` helpers instead of `std`.
    -3. Expose a top-level `Contract` struct marked with `#[contract]`.
    -4. Model errors with `#[contracterror]`; surface via `panic_with_error!`.
    -5. Tests belong under `contracts/*/src/tests` using `soroban_sdk::testutils`.
    -6. New contracts must be registered in `contracts/Cargo.toml` workspace members.
    -
    ----
    -
    -## 6  Zephyr Program Rules
    -
    -• Zephyr code resides in `zephyr/src` and **must remain deterministic** – avoid non-deterministic data sources (e.g. system time, randomness) unless explicitly mocked.
    -• Each externally exposed endpoint must be documented *above* its `#[no_mangle]` function.
    -
    ----
    -
    -## 7  Svelte / Demo Rules
    -
    -• Demo code lives in `demo/src`.
    -• Follow existing component conventions: PascalCase filenames, `