From 67cc1f67d8936fe33a7cfd2944b453114c6a3602 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 14:19:33 +0700 Subject: [PATCH 01/13] refactor(tests-sdk): migrate from solita-client to @lazorkit/sdk-legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the integration test imports from the in-repo Solita-generated client to @lazorkit/sdk-legacy as a file: dependency, on the way to deleting sdk/solita-client entirely. Path changes: - '../../sdk/solita-client/src{,/utils/*,/generated/accounts}' → '@lazorkit/sdk-legacy' - PROGRAM_ID is now imported from ./common (which already hardcodes the foundation-devnet ID FLb7…) rather than from the SDK, since the SDK's exports differ between solita-client (single PROGRAM_ID) and sdk-legacy (PROGRAM_ID_MAINNET / _DEVNET / _FOUNDATION_DEVNET). API differences absorbed: - await added on async client.createWallet calls (sdk-legacy probes ProtocolConfig before building the tx; solita-client returned sync). API differences NOT yet absorbed (~100 type errors remaining; tracked as follow-up): - Standalone instruction builders (createCreateWalletIx, createExecuteIx, etc.) and PDA finders (findWalletPda, findAuthorityPda, etc.) now require an explicit `programId` arg in sdk-legacy (post lazorkit-protocol PR #9). Previously solita-client used an ambient PROGRAM_ID. Each call site needs PROGRAM_ID threaded through; that's a separate mechanical pass. The vitest suite will not pass until those ~100 call sites are updated. The sdk/solita-client directory deletion is gated on that completion. --- tests-sdk/package-lock.json | 90 +++++----------------- tests-sdk/package.json | 1 + tests-sdk/tests/01-wallet.test.ts | 13 ++-- tests-sdk/tests/02-authority.test.ts | 8 +- tests-sdk/tests/03-execute.test.ts | 6 +- tests-sdk/tests/04-session.test.ts | 6 +- tests-sdk/tests/05-replay.test.ts | 2 +- tests-sdk/tests/06-counter.test.ts | 4 +- tests-sdk/tests/07-e2e.test.ts | 6 +- tests-sdk/tests/08-deferred.test.ts | 4 +- tests-sdk/tests/09-permissions.test.ts | 6 +- tests-sdk/tests/10-session-execute.test.ts | 4 +- tests-sdk/tests/11-security.test.ts | 16 ++-- tests-sdk/tests/benchmark.ts | 2 +- tests-sdk/tests/devnet-smoke.ts | 6 +- tests-sdk/tests/secp256r1Utils.ts | 2 +- tests-sdk/tsconfig.json | 2 +- 17 files changed, 64 insertions(+), 114 deletions(-) diff --git a/tests-sdk/package-lock.json b/tests-sdk/package-lock.json index 872e8da..7a76e43 100644 --- a/tests-sdk/package-lock.json +++ b/tests-sdk/package-lock.json @@ -8,6 +8,7 @@ "name": "lazorkit-tests-sdk", "version": "1.0.0", "dependencies": { + "@lazorkit/sdk-legacy": "file:../../lazorkit-protocol/sdk/sdk-legacy", "@solana/web3.js": "^1.95.0", "ecdsa-secp256r1": "^1.3.3" }, @@ -17,6 +18,21 @@ "vitest": "^4.0.18" } }, + "../../lazorkit-protocol/sdk/sdk-legacy": { + "name": "@lazorkit/sdk-legacy", + "version": "0.2.0", + "license": "MIT", + "devDependencies": { + "@solana/web3.js": "^1.95.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -26,31 +42,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -69,6 +60,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@lazorkit/sdk-legacy": { + "resolved": "../../lazorkit-protocol/sdk/sdk-legacy", + "link": true + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", @@ -791,21 +786,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1533,21 +1513,6 @@ "@types/node": "*" } }, - "node_modules/rpc-websockets/node_modules/utf-8-validate": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", - "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/rpc-websockets/node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -1744,21 +1709,6 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/tests-sdk/package.json b/tests-sdk/package.json index 8f1f03b..ba543a8 100644 --- a/tests-sdk/package.json +++ b/tests-sdk/package.json @@ -11,6 +11,7 @@ "pretest": "echo 'Ensure solana-test-validator is running with the program loaded'" }, "dependencies": { + "@lazorkit/sdk-legacy": "file:../../lazorkit-protocol/sdk/sdk-legacy", "@solana/web3.js": "^1.95.0", "ecdsa-secp256r1": "^1.3.3" }, diff --git a/tests-sdk/tests/01-wallet.test.ts b/tests-sdk/tests/01-wallet.test.ts index 3ddba4b..bb17233 100644 --- a/tests-sdk/tests/01-wallet.test.ts +++ b/tests-sdk/tests/01-wallet.test.ts @@ -1,15 +1,14 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { Keypair } from '@solana/web3.js'; import * as crypto from 'crypto'; -import { setupTest, sendTx, type TestContext } from './common'; +import { setupTest, sendTx, PROGRAM_ID, type TestContext } from './common'; import { generateMockSecp256r1Key } from './secp256r1Utils'; import { LazorKitClient, AUTH_TYPE_ED25519, AUTH_TYPE_SECP256R1, - PROGRAM_ID, -} from '../../sdk/solita-client/src'; -import { AuthorityAccount } from '../../sdk/solita-client/src/generated/accounts'; + AuthorityAccount, +} from '@lazorkit/sdk-legacy'; describe('CreateWallet', () => { let ctx: TestContext; @@ -24,7 +23,7 @@ describe('CreateWallet', () => { const ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const { instructions, walletPda, authorityPda } = client.createWallet({ + const { instructions, walletPda, authorityPda } = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, @@ -52,7 +51,7 @@ describe('CreateWallet', () => { const key = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const { instructions, authorityPda } = client.createWallet({ + const { instructions, authorityPda } = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { @@ -79,7 +78,7 @@ describe('CreateWallet', () => { const ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const { instructions } = client.createWallet({ + const { instructions } = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, diff --git a/tests-sdk/tests/02-authority.test.ts b/tests-sdk/tests/02-authority.test.ts index d539ff5..dc75989 100644 --- a/tests-sdk/tests/02-authority.test.ts +++ b/tests-sdk/tests/02-authority.test.ts @@ -16,8 +16,8 @@ import { ROLE_SPENDER, ed25519, secp256r1, -} from '../../sdk/solita-client/src'; -import { AuthorityAccount } from '../../sdk/solita-client/src/generated/accounts'; +} from '@lazorkit/sdk-legacy'; +import { AuthorityAccount } from '@lazorkit/sdk-legacy'; describe('Authority Management', () => { let ctx: TestContext; @@ -37,7 +37,7 @@ describe('Authority Management', () => { ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, @@ -152,7 +152,7 @@ describe('Authority Management', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { diff --git a/tests-sdk/tests/03-execute.test.ts b/tests-sdk/tests/03-execute.test.ts index 0fbebb3..832edea 100644 --- a/tests-sdk/tests/03-execute.test.ts +++ b/tests-sdk/tests/03-execute.test.ts @@ -12,7 +12,7 @@ import { LazorKitClient, ed25519, secp256r1, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Execute', () => { let ctx: TestContext; @@ -33,7 +33,7 @@ describe('Execute', () => { ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, @@ -86,7 +86,7 @@ describe('Execute', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { diff --git a/tests-sdk/tests/04-session.test.ts b/tests-sdk/tests/04-session.test.ts index 4d5f66d..dbd0480 100644 --- a/tests-sdk/tests/04-session.test.ts +++ b/tests-sdk/tests/04-session.test.ts @@ -8,8 +8,8 @@ import { getSlot, type TestContext, } from './common'; -import { LazorKitClient, ed25519 } from '../../sdk/solita-client/src'; -import { SessionAccount } from '../../sdk/solita-client/src/generated/accounts'; +import { LazorKitClient, ed25519 } from '@lazorkit/sdk-legacy'; +import { SessionAccount } from '@lazorkit/sdk-legacy'; describe('CreateSession', () => { let ctx: TestContext; @@ -25,7 +25,7 @@ describe('CreateSession', () => { ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, diff --git a/tests-sdk/tests/05-replay.test.ts b/tests-sdk/tests/05-replay.test.ts index 5f2351b..50770e8 100644 --- a/tests-sdk/tests/05-replay.test.ts +++ b/tests-sdk/tests/05-replay.test.ts @@ -25,7 +25,7 @@ import { computeAccountsHash, AUTH_TYPE_SECP256R1, DISC_EXECUTE, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Replay Prevention (Odometer)', () => { let ctx: TestContext; diff --git a/tests-sdk/tests/06-counter.test.ts b/tests-sdk/tests/06-counter.test.ts index febc83f..6504cad 100644 --- a/tests-sdk/tests/06-counter.test.ts +++ b/tests-sdk/tests/06-counter.test.ts @@ -24,8 +24,8 @@ import { ROLE_SPENDER, DISC_ADD_AUTHORITY, DISC_EXECUTE, -} from '../../sdk/solita-client/src'; -import { AuthorityAccount } from '../../sdk/solita-client/src/generated/accounts'; +} from '@lazorkit/sdk-legacy'; +import { AuthorityAccount } from '@lazorkit/sdk-legacy'; describe('Counter Edge Cases', () => { let ctx: TestContext; diff --git a/tests-sdk/tests/07-e2e.test.ts b/tests-sdk/tests/07-e2e.test.ts index 7f2de23..3c59492 100644 --- a/tests-sdk/tests/07-e2e.test.ts +++ b/tests-sdk/tests/07-e2e.test.ts @@ -15,8 +15,8 @@ import { ROLE_SPENDER, ed25519, secp256r1, -} from '../../sdk/solita-client/src'; -import { AuthorityAccount } from '../../sdk/solita-client/src/generated/accounts'; +} from '@lazorkit/sdk-legacy'; +import { AuthorityAccount } from '@lazorkit/sdk-legacy'; /** * E2E Company Workflow: @@ -53,7 +53,7 @@ describe('E2E Company Workflow', () => { it('Step 1: CEO creates wallet with passkey', async () => { const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { diff --git a/tests-sdk/tests/08-deferred.test.ts b/tests-sdk/tests/08-deferred.test.ts index 41ffa7b..9baed7d 100644 --- a/tests-sdk/tests/08-deferred.test.ts +++ b/tests-sdk/tests/08-deferred.test.ts @@ -11,6 +11,7 @@ import { sendTx, sendTxExpectError, getSlot, + PROGRAM_ID, type TestContext, } from './common'; import { generateMockSecp256r1Key, signSecp256r1 } from './secp256r1Utils'; @@ -30,8 +31,7 @@ import { buildSecp256r1Challenge, AUTH_TYPE_SECP256R1, DISC_AUTHORIZE, - PROGRAM_ID, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Deferred Execution', () => { let ctx: TestContext; diff --git a/tests-sdk/tests/09-permissions.test.ts b/tests-sdk/tests/09-permissions.test.ts index 41338de..9d94d7a 100644 --- a/tests-sdk/tests/09-permissions.test.ts +++ b/tests-sdk/tests/09-permissions.test.ts @@ -26,7 +26,7 @@ import { ROLE_SPENDER, ed25519, secp256r1, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Permission Boundaries', () => { let ctx: TestContext; @@ -50,7 +50,7 @@ describe('Permission Boundaries', () => { ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const walletResult = client.createWallet({ + const walletResult = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, @@ -220,7 +220,7 @@ describe('Permission Boundaries', () => { secpOwnerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { diff --git a/tests-sdk/tests/10-session-execute.test.ts b/tests-sdk/tests/10-session-execute.test.ts index 46625f9..45c6ab1 100644 --- a/tests-sdk/tests/10-session-execute.test.ts +++ b/tests-sdk/tests/10-session-execute.test.ts @@ -25,7 +25,7 @@ import { LazorKitClient, ed25519, session, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Session Execute', () => { let ctx: TestContext; @@ -43,7 +43,7 @@ describe('Session Execute', () => { ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, diff --git a/tests-sdk/tests/11-security.test.ts b/tests-sdk/tests/11-security.test.ts index 7e8a072..a583355 100644 --- a/tests-sdk/tests/11-security.test.ts +++ b/tests-sdk/tests/11-security.test.ts @@ -41,7 +41,7 @@ import { packCompactInstructions, computeAccountsHash, DISC_EXECUTE, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Security', () => { let ctx: TestContext; @@ -63,7 +63,7 @@ describe('Security', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { @@ -154,7 +154,7 @@ describe('Security', () => { const ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, @@ -194,7 +194,7 @@ describe('Security', () => { // Create wallet A const ownerA = Keypair.generate(); const seedA = crypto.randomBytes(32); - const resultA = client.createWallet({ + const resultA = await client.createWallet({ payer: ctx.payer.publicKey, userSeed: seedA, owner: { type: 'ed25519', publicKey: ownerA.publicKey }, @@ -206,7 +206,7 @@ describe('Security', () => { // Create wallet B const ownerB = Keypair.generate(); const seedB = crypto.randomBytes(32); - const resultB = client.createWallet({ + const resultB = await client.createWallet({ payer: ctx.payer.publicKey, userSeed: seedB, owner: { type: 'ed25519', publicKey: ownerB.publicKey }, @@ -238,7 +238,7 @@ describe('Security', () => { const ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { @@ -292,7 +292,7 @@ describe('Security', () => { // Build layout with recipientA const fixedAccounts = [ctx.payer.publicKey, result.walletPda, authorityPda, result.vaultPda, SYSVAR_INSTRUCTIONS_PUBKEY]; const { compactInstructions, remainingAccounts } = - (await import('../../sdk/solita-client/src/utils/compact')).buildCompactLayout(fixedAccounts, [transferIx]); + (await import('@lazorkit/sdk-legacy')).buildCompactLayout(fixedAccounts, [transferIx]); const packed = packCompactInstructions(compactInstructions); // Compute accounts hash with recipientA (the one we sign) @@ -306,7 +306,7 @@ describe('Security', () => { ]; const accountsHash = computeAccountsHash(allAccountMetas, compactInstructions); - const { concatParts } = await import('../../sdk/solita-client/src/utils/signing'); + const { concatParts } = await import('@lazorkit/sdk-legacy'); const signedPayload = concatParts([packed, accountsHash]); // Sign with the correct data (recipientA in accounts hash) diff --git a/tests-sdk/tests/benchmark.ts b/tests-sdk/tests/benchmark.ts index 9c14f81..7b071fe 100644 --- a/tests-sdk/tests/benchmark.ts +++ b/tests-sdk/tests/benchmark.ts @@ -43,7 +43,7 @@ import { createAuthorizeIx, createExecuteDeferredIx, computeInstructionsHash, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; import { generateMockSecp256r1Key, signSecp256r1 } from './secp256r1Utils'; const RPC_URL = process.env.RPC_URL || 'http://127.0.0.1:8899'; diff --git a/tests-sdk/tests/devnet-smoke.ts b/tests-sdk/tests/devnet-smoke.ts index 35b162b..295971c 100644 --- a/tests-sdk/tests/devnet-smoke.ts +++ b/tests-sdk/tests/devnet-smoke.ts @@ -24,7 +24,7 @@ import { session, ROLE_ADMIN, ROLE_SPENDER, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; import { generateMockSecp256r1Key, createMockSigner } from './secp256r1Utils'; const RPC_URL = process.env.RPC_URL || 'https://api.devnet.solana.com'; @@ -125,7 +125,7 @@ async function main() { { ed25519OwnerKp = Keypair.generate(); const balBefore = await connection.getBalance(payer.publicKey); - const { instructions, walletPda, vaultPda, authorityPda } = client.createWallet({ + const { instructions, walletPda, vaultPda, authorityPda } = await client.createWallet({ payer: payer.publicKey, userSeed: crypto.randomBytes(32), owner: { type: 'ed25519', publicKey: ed25519OwnerKp.publicKey }, @@ -144,7 +144,7 @@ async function main() { { secpOwnerKey = await generateMockSecp256r1Key('lazorkit.app'); const balBefore = await connection.getBalance(payer.publicKey); - const { instructions, walletPda, vaultPda, authorityPda } = client.createWallet({ + const { instructions, walletPda, vaultPda, authorityPda } = await client.createWallet({ payer: payer.publicKey, userSeed: crypto.randomBytes(32), owner: { diff --git a/tests-sdk/tests/secp256r1Utils.ts b/tests-sdk/tests/secp256r1Utils.ts index afbf989..aba0550 100644 --- a/tests-sdk/tests/secp256r1Utils.ts +++ b/tests-sdk/tests/secp256r1Utils.ts @@ -10,7 +10,7 @@ import { buildSecp256r1Challenge, generateAuthenticatorData, type Secp256r1Signer, -} from '../../sdk/solita-client/src/utils/secp256r1'; +} from '@lazorkit/sdk-legacy'; import { PROGRAM_ID } from './common'; const SECP256R1_PROGRAM_ID = new PublicKey('Secp256r1SigVerify1111111111111111111111111'); diff --git a/tests-sdk/tsconfig.json b/tests-sdk/tsconfig.json index 0e007c2..880eff3 100644 --- a/tests-sdk/tsconfig.json +++ b/tests-sdk/tsconfig.json @@ -10,5 +10,5 @@ "resolveJsonModule": true, "noEmit": true }, - "include": ["tests/**/*", "../sdk/solita-client/src/**/*"] + "include": ["tests/**/*"] } From e81f12658c4d00da5c885757b918202606fcce71 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 14:27:50 +0700 Subject: [PATCH 02/13] refactor(tests-sdk): thread programId through call sites + adapt secp256r1 mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pass of the migration to @lazorkit/sdk-legacy. tsc now passes (0 errors). Three classes of fixes: 1. PDA finders (find{Wallet,Vault,Authority,Session,DeferredExec}Pda): sdk-legacy requires explicit programId after lazorkit-protocol PR #9. Threaded `PROGRAM_ID` (from ./common) through all call sites. 2. Instruction builders (createCreateWalletIx, createExecuteIx, etc.): Same — added `programId: PROGRAM_ID` to the object args of every call. 3. secp256r1 mock signer: sdk-legacy's WebAuthn signing flow embeds clientDataJson directly into the auth payload (vs solita-client's older typeAndFlags shortcut). Replaced secp256r1Utils.ts with the version from lazorkit-protocol's tests-sdk and imported the helpers from @lazorkit/sdk-legacy. Added backwards-compat aliases (createMockSigner = createMockRawSigner, signSecp256r1 = signSecp256r1Raw) so existing test code is unchanged. Other touch-ups: - tests-sdk/package.json description updated. - benchmark.ts: PROGRAM_ID moved to ./common import. - devnet-smoke.ts: missing await on client.executeDeferredFromPayload. The sdk/solita-client directory can now be deleted in a follow-up commit once vitest is run end-to-end against the migrated tests (requires a local-validator + the program-v2 SBF binary). --- tests-sdk/package.json | 2 +- tests-sdk/tests/05-replay.test.ts | 8 ++- tests-sdk/tests/06-counter.test.ts | 21 ++++--- tests-sdk/tests/08-deferred.test.ts | 51 ++++++++++++---- tests-sdk/tests/11-security.test.ts | 1 + tests-sdk/tests/benchmark.ts | 77 ++++++++++++++--------- tests-sdk/tests/devnet-smoke.ts | 2 +- tests-sdk/tests/secp256r1Utils.ts | 95 +++++++++++++++++++++-------- 8 files changed, 178 insertions(+), 79 deletions(-) diff --git a/tests-sdk/package.json b/tests-sdk/package.json index ba543a8..4d8f79c 100644 --- a/tests-sdk/package.json +++ b/tests-sdk/package.json @@ -2,7 +2,7 @@ "name": "lazorkit-tests-sdk", "version": "1.0.0", "private": true, - "description": "LazorKit SDK integration tests (solita-client + @solana/web3.js v1)", + "description": "LazorKit SDK integration tests (@lazorkit/sdk-legacy + @solana/web3.js v1)", "scripts": { "test": "vitest run --fileParallelism=false", "test:watch": "vitest --fileParallelism=false", diff --git a/tests-sdk/tests/05-replay.test.ts b/tests-sdk/tests/05-replay.test.ts index 50770e8..855a1f4 100644 --- a/tests-sdk/tests/05-replay.test.ts +++ b/tests-sdk/tests/05-replay.test.ts @@ -12,6 +12,7 @@ import { sendTx, sendTxExpectError, getSlot, + PROGRAM_ID, type TestContext, } from './common'; import { generateMockSecp256r1Key, signSecp256r1 } from './secp256r1Utils'; @@ -92,6 +93,7 @@ describe('Replay Prevention (Odometer)', () => { { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); return { precompileIx, ix }; @@ -103,11 +105,12 @@ describe('Replay Prevention (Odometer)', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - [walletPda] = findWalletPda(userSeed); - [vaultPda] = findVaultPda(walletPda); + [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); const [authPda, authBump] = findAuthorityPda( walletPda, ownerKey.credentialIdHash, + PROGRAM_ID, ); ownerAuthorityPda = authPda; @@ -123,6 +126,7 @@ describe('Replay Prevention (Odometer)', () => { credentialOrPubkey: ownerKey.credentialIdHash, secp256r1Pubkey: ownerKey.publicKeyBytes, rpId: ownerKey.rpId, + programId: PROGRAM_ID, }), ]); diff --git a/tests-sdk/tests/06-counter.test.ts b/tests-sdk/tests/06-counter.test.ts index 6504cad..61ff9cd 100644 --- a/tests-sdk/tests/06-counter.test.ts +++ b/tests-sdk/tests/06-counter.test.ts @@ -7,7 +7,7 @@ import { type AccountMeta, } from '@solana/web3.js'; import * as crypto from 'crypto'; -import { setupTest, sendTx, getSlot, type TestContext } from './common'; +import { setupTest, sendTx, getSlot, PROGRAM_ID, type TestContext } from './common'; import { generateMockSecp256r1Key, signSecp256r1 } from './secp256r1Utils'; import { findWalletPda, @@ -39,11 +39,12 @@ describe('Counter Edge Cases', () => { const ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); const [ownerAuthPda, authBump] = findAuthorityPda( walletPda, ownerKey.credentialIdHash, + PROGRAM_ID, ); await sendTx(ctx, [ @@ -58,6 +59,7 @@ describe('Counter Edge Cases', () => { credentialOrPubkey: ownerKey.credentialIdHash, secp256r1Pubkey: ownerKey.publicKeyBytes, rpId: ownerKey.rpId, + programId: PROGRAM_ID, }), ]); @@ -73,7 +75,7 @@ describe('Counter Edge Cases', () => { // 1. AddAuthority (counter becomes 1) const adminKp = Keypair.generate(); const adminPubkey = adminKp.publicKey.toBytes(); - const [adminAuthPda] = findAuthorityPda(walletPda, adminPubkey); + const [adminAuthPda] = findAuthorityPda(walletPda, adminPubkey, PROGRAM_ID); const slot1 = await getSlot(ctx); const dataPayload = Buffer.concat([ @@ -108,6 +110,7 @@ describe('Counter Edge Cases', () => { newRole: ROLE_ADMIN, credentialOrPubkey: adminPubkey, authPayload: ap1, + programId: PROGRAM_ID, }), ]); @@ -174,6 +177,7 @@ describe('Counter Edge Cases', () => { }, { pubkey: execRecipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); @@ -190,11 +194,12 @@ describe('Counter Edge Cases', () => { const key2 = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); const [auth1Pda, auth1Bump] = findAuthorityPda( walletPda, key1.credentialIdHash, + PROGRAM_ID, ); // Create wallet with key1 as owner @@ -210,11 +215,12 @@ describe('Counter Edge Cases', () => { credentialOrPubkey: key1.credentialIdHash, secp256r1Pubkey: key1.publicKeyBytes, rpId: key1.rpId, + programId: PROGRAM_ID, }), ]); // Add key2 as spender via key1 (counter1 goes to 1) - const [auth2Pda] = findAuthorityPda(walletPda, key2.credentialIdHash); + const [auth2Pda] = findAuthorityPda(walletPda, key2.credentialIdHash, PROGRAM_ID); const slot = await getSlot(ctx); const rpIdBytes = Buffer.from(key2.rpId, 'utf-8'); @@ -255,6 +261,7 @@ describe('Counter Edge Cases', () => { secp256r1Pubkey: key2.publicKeyBytes, rpId: key2.rpId, authPayload, + programId: PROGRAM_ID, }), ]); diff --git a/tests-sdk/tests/08-deferred.test.ts b/tests-sdk/tests/08-deferred.test.ts index 9baed7d..777045c 100644 --- a/tests-sdk/tests/08-deferred.test.ts +++ b/tests-sdk/tests/08-deferred.test.ts @@ -50,12 +50,13 @@ describe('Deferred Execution', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - [walletPda] = findWalletPda(userSeed); - [vaultPda] = findVaultPda(walletPda); + [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); const [authPda, authBump] = findAuthorityPda( walletPda, ownerKey.credentialIdHash, - ); + PROGRAM_ID, + ); ownerAuthorityPda = authPda; await sendTx(ctx, [ @@ -70,6 +71,7 @@ describe('Deferred Execution', () => { credentialOrPubkey: ownerKey.credentialIdHash, secp256r1Pubkey: ownerKey.publicKeyBytes, rpId: ownerKey.rpId, + programId: PROGRAM_ID, }), ]); @@ -133,7 +135,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 1, - ); + PROGRAM_ID, + ); // === TX1: Authorize === const authorizeIx = createAuthorizeIx({ @@ -145,6 +148,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }); await sendTx(ctx, [precompileIx, authorizeIx]); @@ -173,6 +177,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); const balanceBefore = await ctx.connection.getBalance(recipient); @@ -252,7 +257,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 2, - ); + PROGRAM_ID, + ); // TX1: Authorize await sendTx(ctx, [ @@ -266,6 +272,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }), ]); @@ -289,6 +296,7 @@ describe('Deferred Execution', () => { { pubkey: recipient2, isSigner: false, isWritable: true }, { pubkey: recipient3, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); @@ -312,12 +320,13 @@ describe('Deferred Execution', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - [walletPda] = findWalletPda(userSeed); - [vaultPda] = findVaultPda(walletPda); + [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); const [authPda, authBump] = findAuthorityPda( walletPda, ownerKey.credentialIdHash, - ); + PROGRAM_ID, + ); ownerAuthorityPda = authPda; await sendTx(ctx, [ @@ -332,6 +341,7 @@ describe('Deferred Execution', () => { credentialOrPubkey: ownerKey.credentialIdHash, secp256r1Pubkey: ownerKey.publicKeyBytes, rpId: ownerKey.rpId, + programId: PROGRAM_ID, }), ]); @@ -386,7 +396,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 1, - ); + PROGRAM_ID, + ); // TX1: Authorize await sendTx(ctx, [ @@ -400,6 +411,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }), ]); @@ -435,6 +447,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ], [], @@ -459,6 +472,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); }); @@ -506,7 +520,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 2, - ); + PROGRAM_ID, + ); // TX1: Authorize await sendTx(ctx, [ @@ -520,6 +535,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }), ]); @@ -540,6 +556,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); await sendTx(ctx, [executeDeferredIx]); @@ -561,6 +578,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); }); @@ -608,7 +626,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 3, - ); + PROGRAM_ID, + ); // TX1: Authorize with max expiry await sendTx(ctx, [ @@ -622,6 +641,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 9000, // ~1 hour authPayload, + programId: PROGRAM_ID, }), ]); @@ -633,6 +653,7 @@ describe('Deferred Execution', () => { payer: ctx.payer.publicKey, deferredExecPda, refundDestination: ctx.payer.publicKey, + programId: PROGRAM_ID, }), ], [], @@ -657,6 +678,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); }); @@ -704,7 +726,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 4, - ); + PROGRAM_ID, + ); // TX1: Authorize with short expiry await sendTx(ctx, [ @@ -718,6 +741,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 10, // minimum expiry authPayload, + programId: PROGRAM_ID, }), ]); @@ -742,6 +766,7 @@ describe('Deferred Execution', () => { payer: wrongPayer.publicKey, deferredExecPda, refundDestination: wrongPayer.publicKey, + programId: PROGRAM_ID, }), ], [wrongPayer], @@ -769,6 +794,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); } catch { @@ -778,6 +804,7 @@ describe('Deferred Execution', () => { payer: ctx.payer.publicKey, deferredExecPda, refundDestination: ctx.payer.publicKey, + programId: PROGRAM_ID, }), ]); } diff --git a/tests-sdk/tests/11-security.test.ts b/tests-sdk/tests/11-security.test.ts index a583355..269eb75 100644 --- a/tests-sdk/tests/11-security.test.ts +++ b/tests-sdk/tests/11-security.test.ts @@ -336,6 +336,7 @@ describe('Security', () => { packedInstructions: packed, authPayload, remainingAccounts: tamperedRemaining, + programId: PROGRAM_ID, }); // Should fail — accounts hash won't match diff --git a/tests-sdk/tests/benchmark.ts b/tests-sdk/tests/benchmark.ts index 7b071fe..c91b4e0 100644 --- a/tests-sdk/tests/benchmark.ts +++ b/tests-sdk/tests/benchmark.ts @@ -39,11 +39,11 @@ import { DISC_EXECUTE, DISC_AUTHORIZE, ROLE_ADMIN, - PROGRAM_ID, createAuthorizeIx, createExecuteDeferredIx, computeInstructionsHash, } from '@lazorkit/sdk-legacy'; +import { PROGRAM_ID } from './common'; import { generateMockSecp256r1Key, signSecp256r1 } from './secp256r1Utils'; const RPC_URL = process.env.RPC_URL || 'http://127.0.0.1:8899'; @@ -133,9 +133,9 @@ async function benchCreateWalletEd25519(connection: Connection, payer: Keypair): const userSeed = crypto.randomBytes(32); const pubkeyBytes = ownerKp.publicKey.toBytes(); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes, PROGRAM_ID); const ix = createCreateWalletIx({ payer: payer.publicKey, @@ -146,6 +146,7 @@ async function benchCreateWalletEd25519(connection: Connection, payer: Keypair): authType: AUTH_TYPE_ED25519, authBump, credentialOrPubkey: pubkeyBytes, + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [ix]); @@ -163,9 +164,9 @@ async function benchCreateWalletSecp256r1(connection: Connection, payer: Keypair const key = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash, PROGRAM_ID); const ix = createCreateWalletIx({ payer: payer.publicKey, @@ -178,6 +179,7 @@ async function benchCreateWalletSecp256r1(connection: Connection, payer: Keypair credentialOrPubkey: key.credentialIdHash, secp256r1Pubkey: key.publicKeyBytes, rpId: key.rpId, + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [ix]); @@ -197,9 +199,9 @@ async function benchAddAuthorityEd25519(connection: Connection, payer: Keypair): const userSeed = crypto.randomBytes(32); const pubkeyBytes = ownerKp.publicKey.toBytes(); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -210,12 +212,13 @@ async function benchAddAuthorityEd25519(connection: Connection, payer: Keypair): authType: AUTH_TYPE_ED25519, authBump, credentialOrPubkey: pubkeyBytes, + programId: PROGRAM_ID, })]); // Now add a new Ed25519 authority (admin adds spender) const newKp = Keypair.generate(); const newPubkey = newKp.publicKey.toBytes(); - const [newAuthPda] = findAuthorityPda(walletPda, newPubkey); + const [newAuthPda] = findAuthorityPda(walletPda, newPubkey, PROGRAM_ID); const ix = createAddAuthorityIx({ payer: payer.publicKey, @@ -226,6 +229,7 @@ async function benchAddAuthorityEd25519(connection: Connection, payer: Keypair): newRole: ROLE_ADMIN, credentialOrPubkey: newPubkey, authorizerSigner: ownerKp.publicKey, + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [ix], [ownerKp]); @@ -244,9 +248,9 @@ async function benchExecuteSecp256r1(connection: Connection, payer: Keypair): Pr const key = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -259,6 +263,7 @@ async function benchExecuteSecp256r1(connection: Connection, payer: Keypair): Pr credentialOrPubkey: key.credentialIdHash, secp256r1Pubkey: key.publicKeyBytes, rpId: key.rpId, + programId: PROGRAM_ID, })]); // Fund vault @@ -318,6 +323,7 @@ async function benchExecuteSecp256r1(connection: Connection, payer: Keypair): Pr { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [precompileIx, ix]); @@ -337,9 +343,9 @@ async function benchCreateSession(connection: Connection, payer: Keypair): Promi const userSeed = crypto.randomBytes(32); const pubkeyBytes = ownerKp.publicKey.toBytes(); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -350,11 +356,12 @@ async function benchCreateSession(connection: Connection, payer: Keypair): Promi authType: AUTH_TYPE_ED25519, authBump, credentialOrPubkey: pubkeyBytes, + programId: PROGRAM_ID, })]); const sessionKp = Keypair.generate(); const sessionKeyBytes = sessionKp.publicKey.toBytes(); - const [sessionPda] = findSessionPda(walletPda, sessionKeyBytes); + const [sessionPda] = findSessionPda(walletPda, sessionKeyBytes, PROGRAM_ID); const currentSlot = await getSlot(connection); const expiresAt = currentSlot + 9000n; @@ -367,6 +374,7 @@ async function benchCreateSession(connection: Connection, payer: Keypair): Promi sessionKey: sessionKeyBytes, expiresAt, authorizerSigner: ownerKp.publicKey, + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [ix], [ownerKp]); @@ -386,9 +394,9 @@ async function benchExecuteSession(connection: Connection, payer: Keypair): Prom const userSeed = crypto.randomBytes(32); const pubkeyBytes = ownerKp.publicKey.toBytes(); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -399,12 +407,13 @@ async function benchExecuteSession(connection: Connection, payer: Keypair): Prom authType: AUTH_TYPE_ED25519, authBump, credentialOrPubkey: pubkeyBytes, + programId: PROGRAM_ID, })]); // Create session const sessionKp = Keypair.generate(); const sessionKeyBytes = sessionKp.publicKey.toBytes(); - const [sessionPda] = findSessionPda(walletPda, sessionKeyBytes); + const [sessionPda] = findSessionPda(walletPda, sessionKeyBytes, PROGRAM_ID); const currentSlot = await getSlot(connection); const expiresAt = currentSlot + 9000n; @@ -417,6 +426,7 @@ async function benchExecuteSession(connection: Connection, payer: Keypair): Prom sessionKey: sessionKeyBytes, expiresAt, authorizerSigner: ownerKp.publicKey, + programId: PROGRAM_ID, })], [ownerKp]); // Fund vault @@ -450,6 +460,7 @@ async function benchExecuteSession(connection: Connection, payer: Keypair): Prom { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [ix], [sessionKp]); @@ -473,9 +484,9 @@ async function benchDeferredExecution( const key = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -488,6 +499,7 @@ async function benchDeferredExecution( credentialOrPubkey: key.credentialIdHash, secp256r1Pubkey: key.publicKeyBytes, rpId: key.rpId, + programId: PROGRAM_ID, })]); // Fund vault @@ -536,7 +548,7 @@ async function benchDeferredExecution( sysvarIxIndex: 6, }); - const [deferredExecPda] = findDeferredExecPda(walletPda, authPda, 1); + const [deferredExecPda] = findDeferredExecPda(walletPda, authPda, 1, PROGRAM_ID); // === TX1: Authorize === const authorizeIx = createAuthorizeIx({ @@ -548,6 +560,7 @@ async function benchDeferredExecution( accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }); const authResult = await sendAndMeasure(connection, payer, [precompileIx, authorizeIx]); @@ -573,6 +586,7 @@ async function benchDeferredExecution( { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); const execResult = await sendAndMeasure(connection, payer, [executeDeferredIx]); @@ -596,9 +610,9 @@ async function benchDeferredMultiInstruction( const key = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -611,6 +625,7 @@ async function benchDeferredMultiInstruction( credentialOrPubkey: key.credentialIdHash, secp256r1Pubkey: key.publicKeyBytes, rpId: key.rpId, + programId: PROGRAM_ID, })]); // Fund vault @@ -663,7 +678,7 @@ async function benchDeferredMultiInstruction( sysvarIxIndex: 6, }); - const [deferredExecPda] = findDeferredExecPda(walletPda, authPda, 1); + const [deferredExecPda] = findDeferredExecPda(walletPda, authPda, 1, PROGRAM_ID); // TX1: Authorize const authorizeIx = createAuthorizeIx({ @@ -675,6 +690,7 @@ async function benchDeferredMultiInstruction( accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }); const authResult = await sendAndMeasure(connection, payer, [precompileIx, authorizeIx]); @@ -702,6 +718,7 @@ async function benchDeferredMultiInstruction( { pubkey: recipient2, isSigner: false, isWritable: true }, { pubkey: recipient3, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); const execResult = await sendAndMeasure(connection, payer, [executeDeferredIx]); diff --git a/tests-sdk/tests/devnet-smoke.ts b/tests-sdk/tests/devnet-smoke.ts index 295971c..866dd0f 100644 --- a/tests-sdk/tests/devnet-smoke.ts +++ b/tests-sdk/tests/devnet-smoke.ts @@ -449,7 +449,7 @@ async function main() { record('Authorize (Deferred TX1, Secp256r1)', r1); // TX2: ExecuteDeferred - const { instructions: execIxs } = client.executeDeferredFromPayload({ + const { instructions: execIxs } = await client.executeDeferredFromPayload({ payer: payer.publicKey, deferredPayload, }); diff --git a/tests-sdk/tests/secp256r1Utils.ts b/tests-sdk/tests/secp256r1Utils.ts index aba0550..4ff7e47 100644 --- a/tests-sdk/tests/secp256r1Utils.ts +++ b/tests-sdk/tests/secp256r1Utils.ts @@ -7,9 +7,11 @@ import { } from '@solana/web3.js'; import { buildAuthPayload, + buildAuthPayloadPrefix, buildSecp256r1Challenge, generateAuthenticatorData, type Secp256r1Signer, + type WebAuthnResponse, } from '@lazorkit/sdk-legacy'; import { PROGRAM_ID } from './common'; @@ -70,15 +72,10 @@ function bytesToBase64UrlNoPad(bytes: Uint8Array): string { } /** - * Creates a Secp256r1Signer from a mock key, compatible with LazorKitClient. - * - * The signer implements the full WebAuthn-compatible signing flow: - * 1. Generates authenticatorData from rpId - * 2. Builds clientDataJSON with the challenge - * 3. Signs authenticatorData + SHA256(clientDataJSON) - * 4. Returns { signature (low-S), authenticatorData, clientDataJsonHash } + * Creates a Secp256r1Signer that returns raw clientDataJSON — the only + * supported auth mode. Simulates a real browser authenticator. */ -export function createMockSigner(key: MockSecp256r1Key): Secp256r1Signer { +export function createMockRawSigner(key: MockSecp256r1Key): Secp256r1Signer { return { publicKeyBytes: key.publicKeyBytes, credentialIdHash: key.credentialIdHash, @@ -92,19 +89,49 @@ export function createMockSigner(key: MockSecp256r1Key): Secp256r1Signer { origin: `https://${key.rpId}`, crossOrigin: false, }); + const clientDataJsonBytes = new Uint8Array(Buffer.from(clientDataJson, 'utf-8')); const clientDataJsonHash = new Uint8Array( - crypto.createHash('sha256').update(clientDataJson).digest(), + crypto.createHash('sha256').update(clientDataJsonBytes).digest(), ); const messageToSign = Buffer.concat([authenticatorData, clientDataJsonHash]); const signatureBase64 = await key.privateKey.sign(Buffer.from(messageToSign)); const signature = enforceLowS(new Uint8Array(Buffer.from(signatureBase64, 'base64'))); - return { signature, authenticatorData, clientDataJsonHash }; + return { signature, authenticatorData, clientDataJsonHash, clientDataJson: clientDataJsonBytes }; }, }; } +/** + * Simulates what navigator.credentials.get() returns. + * Takes a challenge and returns a WebAuthn response — use this with the + * prepare/finalize flow to fake the browser authenticator step. + */ +export async function fakeWebAuthnSign( + key: MockSecp256r1Key, + challenge: Uint8Array, +): Promise { + const authenticatorData = generateAuthenticatorData(key.rpId); + + const clientDataJson = JSON.stringify({ + type: 'webauthn.get', + challenge: bytesToBase64UrlNoPad(challenge), + origin: `https://${key.rpId}`, + crossOrigin: false, + }); + const clientDataJsonBytes = new Uint8Array(Buffer.from(clientDataJson, 'utf-8')); + const clientDataJsonHash = new Uint8Array( + crypto.createHash('sha256').update(clientDataJsonBytes).digest(), + ); + + const messageToSign = Buffer.concat([authenticatorData, clientDataJsonHash]); + const signatureBase64 = await key.privateKey.sign(Buffer.from(messageToSign)); + const signature = enforceLowS(new Uint8Array(Buffer.from(signatureBase64, 'base64'))); + + return { signature, authenticatorData, clientDataJsonHash, clientDataJson: clientDataJsonBytes }; +} + /** * Full Secp256r1 signing flow for low-level tests. * Builds auth payload, computes challenge hash, signs it via WebAuthn-compatible @@ -112,7 +139,12 @@ export function createMockSigner(key: MockSecp256r1Key): Secp256r1Signer { * * Use `createMockSigner()` + `LazorKitClient` for simpler tests. */ -export async function signSecp256r1(params: { +/** + * Full Secp256r1 signing flow (raw clientDataJSON) for low-level tests. + * Uses the 14-byte prefix for challenge computation, then builds the full + * auth payload with authenticatorData + raw clientDataJSON. + */ +export async function signSecp256r1Raw(params: { key: MockSecp256r1Key; discriminator: Uint8Array; signedPayload: Uint8Array; @@ -126,21 +158,18 @@ export async function signSecp256r1(params: { precompileIx: TransactionInstruction; }> { const pid = params.programId ?? PROGRAM_ID; - const authenticatorData = generateAuthenticatorData(params.key.rpId); - // Build auth payload (optimized: no rpId, no slotHashes index, u32 counter) - const authPayload = buildAuthPayload({ + // Mode 1: Use only the 14-byte prefix for challenge computation + const challengePrefix = buildAuthPayloadPrefix({ slot: params.slot, counter: params.counter, sysvarIxIndex: params.sysvarIxIndex, - typeAndFlags: 0x10, // webauthn.get + https - authenticatorData, }); - // Compute challenge hash (7 elements) + // Compute challenge hash with the prefix (not the full payload) const challengeHash = buildSecp256r1Challenge({ discriminator: params.discriminator, - authPayload, + authPayload: challengePrefix, signedPayload: params.signedPayload, slot: params.slot, payer: params.payer, @@ -148,24 +177,34 @@ export async function signSecp256r1(params: { programId: pid, }); - // Build clientDataJSON and compute the actual message to sign + // Build authenticatorData and clientDataJSON + const authenticatorData = generateAuthenticatorData(params.key.rpId); + const clientDataJson = JSON.stringify({ type: 'webauthn.get', challenge: bytesToBase64UrlNoPad(challengeHash), origin: `https://${params.key.rpId}`, crossOrigin: false, }); - const clientDataJsonHash = crypto.createHash('sha256').update(clientDataJson).digest(); - - const messageToSign = Buffer.concat([ - authenticatorData, - clientDataJsonHash, - ]); + const clientDataJsonBytes = new Uint8Array(Buffer.from(clientDataJson, 'utf-8')); + const clientDataJsonHash = new Uint8Array( + crypto.createHash('sha256').update(clientDataJsonBytes).digest(), + ); - // Sign with ecdsa-secp256r1 + // Sign: authenticatorData || clientDataJsonHash + const messageToSign = Buffer.concat([authenticatorData, clientDataJsonHash]); const signatureBase64 = await params.key.privateKey.sign(Buffer.from(messageToSign)); const rawSig = enforceLowS(new Uint8Array(Buffer.from(signatureBase64, 'base64'))); + // Build full auth payload + const authPayload = buildAuthPayload({ + slot: params.slot, + counter: params.counter, + sysvarIxIndex: params.sysvarIxIndex, + authenticatorData, + clientDataJson: clientDataJsonBytes, + }); + // Build precompile instruction const precompileIx = buildPrecompileIx( params.key.publicKeyBytes, @@ -213,3 +252,7 @@ function buildPrecompileIx( data, }); } + +// ─── Backwards-compat aliases (program-v2 tests use older names) ───────── +export const createMockSigner = createMockRawSigner; +export const signSecp256r1 = signSecp256r1Raw; From 781f89e97db7aa429d97c4c90142757f2a609649 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 14:28:37 +0700 Subject: [PATCH 03/13] chore: delete sdk/solita-client (replaced by @lazorkit/sdk-legacy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Solita-generated client is no longer needed. tests-sdk now depends on @lazorkit/sdk-legacy (file: link to ../../lazorkit-protocol/sdk/sdk-legacy during local dev; npm-published version after release). Anyone targeting program-v2 from TypeScript should: npm install @lazorkit/sdk-legacy The SDK probes the on-chain ProtocolConfig PDA on first use: - foundation binary at the slot → no PDA → no fee accounts → no fee - commercial binary at the slot → PDA present → fee accounts appended - same SDK code works against either binary, transparently scripts/build-all.sh and DEVELOPMENT.md still reference solita-client in places — separate cleanup commit follows. --- sdk/solita-client/README.md | 465 ----- sdk/solita-client/generate.mjs | 141 -- sdk/solita-client/idl-enriched.json | 680 ------- sdk/solita-client/package-lock.json | 1577 ----------------- sdk/solita-client/package.json | 21 - .../generated/accounts/AuthorityAccount.ts | 205 --- .../src/generated/accounts/SessionAccount.ts | 200 --- .../src/generated/accounts/WalletAccount.ts | 177 -- .../src/generated/accounts/index.ts | 9 - .../src/generated/errors/index.ts | 318 ---- sdk/solita-client/src/generated/index.ts | 21 - .../generated/instructions/AddAuthority.ts | 126 -- .../generated/instructions/CreateSession.ts | 122 -- .../generated/instructions/CreateWallet.ts | 116 -- .../src/generated/instructions/Execute.ts | 114 -- .../generated/instructions/RemoveAuthority.ts | 109 -- .../instructions/TransferOwnership.ts | 124 -- .../src/generated/instructions/index.ts | 6 - .../generated/types/AccountDiscriminator.ts | 24 - .../src/generated/types/AuthorityType.ts | 22 - sdk/solita-client/src/generated/types/Role.ts | 23 - .../src/generated/types/index.ts | 3 - sdk/solita-client/src/index.ts | 2 - sdk/solita-client/src/utils/client.ts | 694 -------- sdk/solita-client/src/utils/compact.ts | 66 - sdk/solita-client/src/utils/ed25519.ts | 10 - sdk/solita-client/src/utils/errors.ts | 56 - sdk/solita-client/src/utils/index.ts | 10 - sdk/solita-client/src/utils/instructions.ts | 531 ------ sdk/solita-client/src/utils/packing.ts | 77 - sdk/solita-client/src/utils/pdas.ts | 63 - sdk/solita-client/src/utils/secp256r1.ts | 128 -- sdk/solita-client/src/utils/signing.ts | 180 -- sdk/solita-client/src/utils/types.ts | 77 - sdk/solita-client/src/utils/wrapper.ts | 10 - sdk/solita-client/tsconfig.json | 17 - 36 files changed, 6524 deletions(-) delete mode 100644 sdk/solita-client/README.md delete mode 100644 sdk/solita-client/generate.mjs delete mode 100644 sdk/solita-client/idl-enriched.json delete mode 100644 sdk/solita-client/package-lock.json delete mode 100644 sdk/solita-client/package.json delete mode 100644 sdk/solita-client/src/generated/accounts/AuthorityAccount.ts delete mode 100644 sdk/solita-client/src/generated/accounts/SessionAccount.ts delete mode 100644 sdk/solita-client/src/generated/accounts/WalletAccount.ts delete mode 100644 sdk/solita-client/src/generated/accounts/index.ts delete mode 100644 sdk/solita-client/src/generated/errors/index.ts delete mode 100644 sdk/solita-client/src/generated/index.ts delete mode 100644 sdk/solita-client/src/generated/instructions/AddAuthority.ts delete mode 100644 sdk/solita-client/src/generated/instructions/CreateSession.ts delete mode 100644 sdk/solita-client/src/generated/instructions/CreateWallet.ts delete mode 100644 sdk/solita-client/src/generated/instructions/Execute.ts delete mode 100644 sdk/solita-client/src/generated/instructions/RemoveAuthority.ts delete mode 100644 sdk/solita-client/src/generated/instructions/TransferOwnership.ts delete mode 100644 sdk/solita-client/src/generated/instructions/index.ts delete mode 100644 sdk/solita-client/src/generated/types/AccountDiscriminator.ts delete mode 100644 sdk/solita-client/src/generated/types/AuthorityType.ts delete mode 100644 sdk/solita-client/src/generated/types/Role.ts delete mode 100644 sdk/solita-client/src/generated/types/index.ts delete mode 100644 sdk/solita-client/src/index.ts delete mode 100644 sdk/solita-client/src/utils/client.ts delete mode 100644 sdk/solita-client/src/utils/compact.ts delete mode 100644 sdk/solita-client/src/utils/ed25519.ts delete mode 100644 sdk/solita-client/src/utils/errors.ts delete mode 100644 sdk/solita-client/src/utils/index.ts delete mode 100644 sdk/solita-client/src/utils/instructions.ts delete mode 100644 sdk/solita-client/src/utils/packing.ts delete mode 100644 sdk/solita-client/src/utils/pdas.ts delete mode 100644 sdk/solita-client/src/utils/secp256r1.ts delete mode 100644 sdk/solita-client/src/utils/signing.ts delete mode 100644 sdk/solita-client/src/utils/types.ts delete mode 100644 sdk/solita-client/src/utils/wrapper.ts delete mode 100644 sdk/solita-client/tsconfig.json diff --git a/sdk/solita-client/README.md b/sdk/solita-client/README.md deleted file mode 100644 index 03206c5..0000000 --- a/sdk/solita-client/README.md +++ /dev/null @@ -1,465 +0,0 @@ -# @lazorkit/solita-client - -TypeScript SDK for the LazorKit smart wallet program on Solana. Built with `@solana/web3.js` v1 and Solita-generated instruction builders. - -## Installation - -```bash -npm install @lazorkit/solita-client -``` - -## Quick Start - -```typescript -import { Connection, Keypair, Transaction, sendAndConfirmTransaction } from '@solana/web3.js'; -import { LazorKitClient, ed25519, secp256r1, session, ROLE_ADMIN } from '@lazorkit/solita-client'; -import * as crypto from 'crypto'; - -const connection = new Connection('https://api.devnet.solana.com', 'confirmed'); -const client = new LazorKitClient(connection); - -// Create a wallet with Ed25519 owner -const owner = Keypair.generate(); -const { instructions, walletPda, vaultPda, authorityPda } = client.createWallet({ - payer: payer.publicKey, - userSeed: crypto.randomBytes(32), - owner: { type: 'ed25519', publicKey: owner.publicKey }, -}); -await sendAndConfirmTransaction(connection, new Transaction().add(...instructions), [payer]); - -// Or with Secp256r1 (passkey) owner -const { instructions: ixs2 } = client.createWallet({ - payer: payer.publicKey, - userSeed: crypto.randomBytes(32), - owner: { - type: 'secp256r1', - credentialIdHash, // 32-byte SHA256 of WebAuthn credential ID - compressedPubkey, // 33-byte compressed public key - rpId: 'your-app.com', - }, -}); -``` - -## API Reference - -### PDA Helpers - -```typescript -import { findWalletPda, findVaultPda, findAuthorityPda, findSessionPda, findDeferredExecPda } from '@lazorkit/solita-client'; - -// Derive wallet PDA from user seed -const [walletPda, walletBump] = findWalletPda(userSeed); - -// Derive vault PDA from wallet -const [vaultPda, vaultBump] = findVaultPda(walletPda); - -// Derive authority PDA from wallet + credential hash (or pubkey for Ed25519) -const [authorityPda, authBump] = findAuthorityPda(walletPda, credentialIdHash); - -// Derive session PDA from wallet + session key -const [sessionPda, sessionBump] = findSessionPda(walletPda, sessionKeyBytes); - -// Derive deferred execution PDA from wallet + authority + counter -const [deferredPda, deferredBump] = findDeferredExecPda(walletPda, authorityPda, counter); -``` - -### Instruction Builders - -Low-level builders that return `TransactionInstruction`: - -```typescript -import { - createCreateWalletIx, - createAddAuthorityIx, - createRemoveAuthorityIx, - createTransferOwnershipIx, - createExecuteIx, - createCreateSessionIx, - createAuthorizeIx, - createExecuteDeferredIx, - createReclaimDeferredIx, -} from '@lazorkit/solita-client'; -``` - -#### createCreateWalletIx - -```typescript -const ix = createCreateWalletIx({ - payer: PublicKey, - walletPda: PublicKey, - vaultPda: PublicKey, - authorityPda: PublicKey, - userSeed: Uint8Array, // 32 bytes - authType: number, // AUTH_TYPE_ED25519 (0) or AUTH_TYPE_SECP256R1 (1) - authBump: number, - credentialOrPubkey: Uint8Array, // Ed25519: 32-byte pubkey | Secp256r1: 32-byte credential_id_hash - secp256r1Pubkey?: Uint8Array, // Secp256r1 only: 33-byte compressed pubkey - rpId?: string, // Secp256r1 only: relying party ID (e.g., "lazorkit.app") -}); -``` - -#### createExecuteIx - -```typescript -const ix = createExecuteIx({ - payer: PublicKey, - walletPda: PublicKey, - authorityPda: PublicKey, - vaultPda: PublicKey, - packedInstructions: Uint8Array, // From packCompactInstructions() - authPayload?: Uint8Array, // Secp256r1 only - remainingAccounts?: AccountMeta[], -}); -``` - -#### createAuthorizeIx (Deferred Execution TX1) - -```typescript -const ix = createAuthorizeIx({ - payer: PublicKey, - walletPda: PublicKey, - authorityPda: PublicKey, - deferredExecPda: PublicKey, - instructionsHash: Uint8Array, // 32 bytes — SHA256 of packed compact instructions - accountsHash: Uint8Array, // 32 bytes — SHA256 of all referenced account pubkeys - expiryOffset: number, // Slots until expiry (10-9000) - authPayload: Uint8Array, // Secp256r1 auth payload -}); -``` - -#### createExecuteDeferredIx (Deferred Execution TX2) - -```typescript -const ix = createExecuteDeferredIx({ - payer: PublicKey, - walletPda: PublicKey, - vaultPda: PublicKey, - deferredExecPda: PublicKey, - refundDestination: PublicKey, - packedInstructions: Uint8Array, // From packCompactInstructions() - remainingAccounts?: AccountMeta[], // Inner accounts referenced by instructions -}); -``` - -#### createReclaimDeferredIx - -```typescript -const ix = createReclaimDeferredIx({ - payer: PublicKey, - deferredExecPda: PublicKey, - refundDestination: PublicKey, -}); -``` - -#### createRevokeSessionIx - -```typescript -const ix = createRevokeSessionIx({ - payer: PublicKey, - walletPda: PublicKey, - adminAuthorityPda: PublicKey, - sessionPda: PublicKey, - refundDestination: PublicKey, - authPayload?: Uint8Array, // Secp256r1 only - authorizerSigner?: PublicKey, // Ed25519 only -}); -``` - -### Compact Instruction Packing - -Pack multiple instructions for Execute: - -```typescript -import { packCompactInstructions, computeAccountsHash, computeInstructionsHash } from '@lazorkit/solita-client'; - -// Define compact instructions with account indexes (not pubkeys) -const packed = packCompactInstructions([{ - programIdIndex: 5, // Index of SystemProgram in accounts - accountIndexes: [3, 6], // vault (from), recipient (to) - data: transferData, -}]); - -// For Secp256r1: compute accounts hash for signature binding -const accountsHash = computeAccountsHash(accountMetas, compactInstructions); - -// For deferred execution: compute instructions hash (signed in TX1, verified in TX2) -const instructionsHash = computeInstructionsHash(compactInstructions); -``` - -### Secp256r1 (Passkey) Utilities - -```typescript -import { - readAuthorityCounter, - buildAuthPayload, - buildSecp256r1Challenge, - generateAuthenticatorData, - type Secp256r1Signer, -} from '@lazorkit/solita-client'; - -// Read current counter from on-chain authority account -const counter = await readAuthorityCounter(connection, authorityPda); - -// Generate WebAuthn authenticator data from RP ID (37 bytes: rpIdHash + flags + counter) -const authenticatorData = generateAuthenticatorData('lazorkit.app'); - -// Build auth payload for Secp256r1 signing -const authPayload = buildAuthPayload({ - slot: BigInt(currentSlot), - counter: counter + 1, // number (u32), not bigint - sysvarIxIndex: 4, - typeAndFlags: 0x10, // webauthn.get + https - authenticatorData: authData, // rpId is stored on-chain, not sent per-tx -}); - -// Build challenge hash (7 elements) -const challenge = buildSecp256r1Challenge({ - discriminator: new Uint8Array([4]), // Execute - authPayload, - signedPayload, - slot: BigInt(currentSlot), - payer: payerPublicKey, - counter: counter + 1, // number (u32) -}); -``` - -#### Secp256r1Signer Interface - -```typescript -interface Secp256r1Signer { - publicKeyBytes: Uint8Array; // 33-byte compressed pubkey - credentialIdHash: Uint8Array; // 32-byte SHA256 of credential ID - rpId: string; // e.g., "lazorkit.app" - sign(challenge: Uint8Array): Promise<{ - signature: Uint8Array; // 64-byte raw signature (r||s), low-S normalized - authenticatorData: Uint8Array; // WebAuthn authenticator data - clientDataJsonHash: Uint8Array; // SHA256 of clientDataJSON - }>; -} -``` - -### Signer Types (Discriminated Unions) - -The SDK uses discriminated union types for signers. Helper constructors make creation concise: - -```typescript -import { ed25519, secp256r1, session } from '@lazorkit/solita-client'; - -// Ed25519 — Keypair signs at transaction level -const signer = ed25519(ownerKp.publicKey, authorityPda); // authorityPda optional (auto-derived) - -// Secp256r1 — passkey/WebAuthn -const signer = secp256r1(myPasskeySigner, { authorityPda, slotOverride }); // both optional - -// Session — ephemeral key -const signer = session(sessionPda, sessionKp.publicKey); -``` - -**Type unions:** -- `AdminSigner` = `Ed25519SignerConfig | Secp256r1SignerConfig` -- for admin operations (addAuthority, removeAuthority, transferOwnership, createSession, revokeSession) -- `ExecuteSigner` = above + `SessionSignerConfig` -- for execute/transferSol - -### High-Level Client API - -Every method returns `{ instructions: TransactionInstruction[]; ...extraPdas }`. The client auto-derives PDAs, auto-fetches slots, auto-reads counters, auto-packs compact instructions, and auto-computes accounts hashes. - -```typescript -import { LazorKitClient, ed25519, secp256r1, session, ROLE_ADMIN, ROLE_SPENDER } from '@lazorkit/solita-client'; - -const client = new LazorKitClient(connection); - -// ── Read counter ── -const counter = await client.readCounter(authorityPda); - -// ── Create wallet (unified for both auth types) ── -const { instructions, walletPda, vaultPda, authorityPda } = client.createWallet({ - payer: payer.publicKey, - userSeed, - owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, - // or: owner: { type: 'secp256r1', credentialIdHash, compressedPubkey, rpId: 'app.com' }, -}); - -// ── Add authority (unified — works with any admin signer) ── -const { instructions, newAuthorityPda } = await client.addAuthority({ - payer: payer.publicKey, - walletPda, - adminSigner: ed25519(ownerKp.publicKey, ownerAuthPda), // or secp256r1(signer) - newAuthority: { type: 'ed25519', publicKey: adminKp.publicKey }, - role: ROLE_ADMIN, -}); - -// ── Remove authority ── -const { instructions } = await client.removeAuthority({ - payer: payer.publicKey, - walletPda, - adminSigner: ed25519(adminKp.publicKey, adminAuthPda), - targetAuthorityPda: spenderAuthPda, - // refundDestination defaults to payer -}); - -// ── Transfer ownership ── -const { instructions, newOwnerAuthorityPda } = await client.transferOwnership({ - payer: payer.publicKey, - walletPda, - ownerSigner: secp256r1(ceoSigner), - newOwner: { type: 'secp256r1', credentialIdHash, compressedPubkey, rpId }, -}); - -// ── Execute (unified — Ed25519, Secp256r1, or Session) ── -const [vault] = client.findVault(walletPda); -const { instructions } = await client.execute({ - payer: payer.publicKey, - walletPda, - signer: secp256r1(mySigner), // or ed25519(kp.publicKey) or session(sessionPda, sessionKp.publicKey) - instructions: [ - SystemProgram.transfer({ fromPubkey: vault, toPubkey: recipient, lamports: 1_000_000 }), - ], -}); -// Note: for Ed25519 add ownerKp to tx signers, for Session add sessionKp - -// ── Transfer SOL (convenience) ── -const { instructions } = await client.transferSol({ - payer: payer.publicKey, - walletPda, - signer: secp256r1(mySigner), - recipient, - lamports: 1_000_000n, -}); - -// ── Create session ── -const { instructions, sessionPda } = await client.createSession({ - payer: payer.publicKey, - walletPda, - adminSigner: ed25519(ownerKp.publicKey, ownerAuthPda), - sessionKey: sessionKp.publicKey, - expiresAt: currentSlot + 9000n, -}); - -// ── Deferred Execution — TX1 (Authorize) ── -const { instructions, deferredExecPda, deferredPayload } = await client.authorize({ - payer: payer.publicKey, - walletPda, - signer: secp256r1(mySigner), // Secp256r1 only - instructions: [jupiterSwapIx], - expiryOffset: 300, // ~2 minutes in slots -}); - -// ── Deferred Execution — TX2 (ExecuteDeferred) ── -const { instructions: tx2Ixs } = client.executeDeferredFromPayload({ - payer: payer.publicKey, - deferredPayload, // returned from authorize() - // refundDestination defaults to payer -}); - -// ── Reclaim expired DeferredExec (refund rent) ── -const { instructions } = client.reclaimDeferred({ - payer: payer.publicKey, - deferredExecPda, - // refundDestination defaults to payer -}); - -// ── Revoke session (close early, refund rent) ── -const { instructions } = await client.revokeSession({ - payer: payer.publicKey, - walletPda, - adminSigner: ed25519(ownerKp.publicKey, ownerAuthPda), // or secp256r1(signer) - sessionPda, - // refundDestination defaults to payer -}); -``` - -All Secp256r1 signers accept an optional `slotOverride` for batching scenarios. - -### Constants - -```typescript -// Instruction discriminators -DISC_CREATE_WALLET // 0 -DISC_ADD_AUTHORITY // 1 -DISC_REMOVE_AUTHORITY // 2 -DISC_TRANSFER_OWNERSHIP // 3 -DISC_EXECUTE // 4 -DISC_CREATE_SESSION // 5 -DISC_AUTHORIZE // 6 -DISC_EXECUTE_DEFERRED // 7 -DISC_RECLAIM_DEFERRED // 8 -DISC_REVOKE_SESSION // 9 - -// Auth types -AUTH_TYPE_ED25519 // 0 -AUTH_TYPE_SECP256R1 // 1 - -// Roles -ROLE_OWNER // 0 -ROLE_ADMIN // 1 -ROLE_SPENDER // 2 - -// Program ID -PROGRAM_ID // FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao -``` - -### Error Handling - -```typescript -import { extractErrorCode, ERROR_NAMES } from '@lazorkit/solita-client'; - -try { - await sendAndConfirmTransaction(connection, tx, [payer]); -} catch (err) { - const code = extractErrorCode(err); - if (code) { - console.log(`Error: ${ERROR_NAMES[code]} (${code})`); - } -} -``` - -Error codes: -| Code | Name | Description | -|------|------|-------------| -| 3001 | InvalidAuthorityPayload | Malformed auth payload | -| 3002 | PermissionDenied | Insufficient role permissions | -| 3003 | InvalidInstruction | Precompile instruction verification failed | -| 3004 | InvalidPubkey | Public key mismatch | -| 3005 | InvalidMessageHash | Challenge hash mismatch | -| 3006 | SignatureReused | Counter mismatch (replay attempt) | -| 3007 | InvalidSignatureAge | Slot too old (>150 slots from current) | -| 3008 | InvalidSessionDuration | Session expiry out of range | -| 3009 | SessionExpired | Session past expires_at slot | -| 3010 | AuthorityDoesNotSupportSession | N/A | -| 3011 | InvalidAuthenticationKind | Unknown authority_type | -| 3012 | InvalidMessage | N/A | -| 3013 | SelfReentrancyNotAllowed | CPI back into program rejected | -| 3014 | DeferredAuthorizationExpired | DeferredExec past expires_at slot | -| 3015 | DeferredHashMismatch | Instructions or accounts hash mismatch | -| 3016 | InvalidExpiryWindow | Expiry offset out of range (10-9000 slots) | -| 3017 | UnauthorizedReclaim | Only original payer can reclaim | -| 3018 | DeferredAuthorizationNotExpired | Cannot reclaim before expiry | -| 3019 | InvalidSessionAccount | Invalid session PDA during revocation | - -### Generated Accounts - -Solita-generated account classes with `fromAccountAddress()`: - -```typescript -import { WalletAccount, AuthorityAccount, SessionAccount } from '@lazorkit/solita-client'; - -const wallet = await WalletAccount.fromAccountAddress(connection, walletPda); -const authority = await AuthorityAccount.fromAccountAddress(connection, authorityPda); -const session = await SessionAccount.fromAccountAddress(connection, sessionPda); -``` - -## SDK Regeneration - -After modifying program instructions: - -```bash -# 1. Regenerate IDL -cd program && shank idl -o . --out-filename idl.json -p FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao - -# 2. Regenerate SDK -cd sdk/solita-client && node generate.mjs -``` - -## License - -MIT diff --git a/sdk/solita-client/generate.mjs b/sdk/solita-client/generate.mjs deleted file mode 100644 index 6d00486..0000000 --- a/sdk/solita-client/generate.mjs +++ /dev/null @@ -1,141 +0,0 @@ -/** - * LazorKit Solita Code Generation Script - * - * Reads the Shank IDL, enriches it with account types, error codes, - * and enum types, then generates TypeScript via Solita. - * - * Usage: node generate.mjs - */ -import { readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { Solita } from '@metaplex-foundation/solita'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// ─── 1. Read Shank IDL ─────────────────────────────────────────── -const idlPath = join(__dirname, '../../program/idl.json'); -const idl = JSON.parse(readFileSync(idlPath, 'utf-8')); -console.log('Read IDL from', idlPath); - -// ─── 2. Inject program address ────────────────────────────────── -idl.metadata = idl.metadata || {}; -idl.metadata.address = 'FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao'; - -// ─── 3. Add account types ─────────────────────────────────────── -idl.accounts = [ - { - name: 'WalletAccount', - type: { - kind: 'struct', - fields: [ - { name: 'discriminator', type: 'u8' }, - { name: 'bump', type: 'u8' }, - { name: 'version', type: 'u8' }, - { name: 'padding', type: { array: ['u8', 5] } }, - ], - }, - }, - { - name: 'AuthorityAccount', - type: { - kind: 'struct', - fields: [ - { name: 'discriminator', type: 'u8' }, - { name: 'authorityType', type: 'u8' }, - { name: 'role', type: 'u8' }, - { name: 'bump', type: 'u8' }, - { name: 'version', type: 'u8' }, - { name: 'padding', type: { array: ['u8', 3] } }, - { name: 'counter', type: 'u64' }, - { name: 'wallet', type: 'publicKey' }, - ], - }, - }, - { - name: 'SessionAccount', - type: { - kind: 'struct', - fields: [ - { name: 'discriminator', type: 'u8' }, - { name: 'bump', type: 'u8' }, - { name: 'version', type: 'u8' }, - { name: 'padding', type: { array: ['u8', 5] } }, - { name: 'wallet', type: 'publicKey' }, - { name: 'sessionKey', type: 'publicKey' }, - { name: 'expiresAt', type: 'u64' }, - ], - }, - }, -]; -console.log('Added 3 account types'); - -// ─── 4. Add error codes ───────────────────────────────────────── -idl.errors = [ - { code: 3001, name: 'InvalidAuthorityPayload', msg: 'Invalid authority payload' }, - { code: 3002, name: 'PermissionDenied', msg: 'Permission denied' }, - { code: 3003, name: 'InvalidInstruction', msg: 'Invalid instruction' }, - { code: 3004, name: 'InvalidPubkey', msg: 'Invalid public key' }, - { code: 3005, name: 'InvalidMessageHash', msg: 'Invalid message hash' }, - { code: 3006, name: 'SignatureReused', msg: 'Signature has already been used (counter mismatch)' }, - { code: 3007, name: 'InvalidSignatureAge', msg: 'Signature too old (outside 150-slot window)' }, - { code: 3008, name: 'InvalidSessionDuration', msg: 'Invalid session duration' }, - { code: 3009, name: 'SessionExpired', msg: 'Session has expired' }, - { code: 3010, name: 'AuthorityDoesNotSupportSession', msg: 'Authority type does not support sessions' }, - { code: 3011, name: 'InvalidAuthenticationKind', msg: 'Invalid authentication kind' }, - { code: 3012, name: 'InvalidMessage', msg: 'Invalid message' }, - { code: 3013, name: 'SelfReentrancyNotAllowed', msg: 'Self-reentrancy is not allowed' }, -]; -console.log('Added 13 error codes'); - -// ─── 5. Add enum types ────────────────────────────────────────── -if (!idl.types) idl.types = []; -idl.types.push( - { - name: 'AuthorityType', - type: { - kind: 'enum', - variants: [ - { name: 'Ed25519' }, - { name: 'Secp256r1' }, - ], - }, - }, - { - name: 'Role', - type: { - kind: 'enum', - variants: [ - { name: 'Owner' }, - { name: 'Admin' }, - { name: 'Spender' }, - ], - }, - }, - { - name: 'AccountDiscriminator', - type: { - kind: 'enum', - variants: [ - { name: 'Uninitialized' }, - { name: 'Wallet' }, - { name: 'Authority' }, - { name: 'Session' }, - ], - }, - }, -); -console.log('Added 3 enum types'); - -// ─── 6. Write enriched IDL ────────────────────────────────────── -const enrichedPath = join(__dirname, 'idl-enriched.json'); -writeFileSync(enrichedPath, JSON.stringify(idl, null, 2)); -console.log('Wrote enriched IDL to', enrichedPath); - -// ─── 7. Generate via Solita ───────────────────────────────────── -const outputDir = join(__dirname, 'src', 'generated'); -mkdirSync(outputDir, { recursive: true }); - -const gen = new Solita(idl, { programName: 'lazorkit_program', programId: idl.metadata.address }); -await gen.renderAndWriteTo(outputDir); -console.log('Generated TypeScript to', outputDir); diff --git a/sdk/solita-client/idl-enriched.json b/sdk/solita-client/idl-enriched.json deleted file mode 100644 index 0323563..0000000 --- a/sdk/solita-client/idl-enriched.json +++ /dev/null @@ -1,680 +0,0 @@ -{ - "version": "0.1.0", - "name": "lazorkit_program", - "instructions": [ - { - "name": "CreateWallet", - "accounts": [ - { - "name": "payer", - "isMut": true, - "isSigner": true, - "docs": [ - "Payer and rent contributor" - ] - }, - { - "name": "wallet", - "isMut": true, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "vault", - "isMut": true, - "isSigner": false, - "docs": [ - "Vault PDA" - ] - }, - { - "name": "authority", - "isMut": true, - "isSigner": false, - "docs": [ - "Initial owner authority PDA" - ] - }, - { - "name": "systemProgram", - "isMut": false, - "isSigner": false, - "docs": [ - "System Program" - ] - } - ], - "args": [ - { - "name": "userSeed", - "type": "bytes" - }, - { - "name": "authType", - "type": "u8" - }, - { - "name": "authPubkey", - "type": { - "array": [ - "u8", - 33 - ] - } - }, - { - "name": "credentialHash", - "type": { - "array": [ - "u8", - 32 - ] - } - } - ], - "discriminant": { - "type": "u8", - "value": 0 - } - }, - { - "name": "AddAuthority", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true, - "docs": [ - "Transaction payer" - ] - }, - { - "name": "wallet", - "isMut": false, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "adminAuthority", - "isMut": false, - "isSigner": true, - "docs": [ - "Admin authority PDA authorizing this action" - ] - }, - { - "name": "newAuthority", - "isMut": true, - "isSigner": false, - "docs": [ - "New authority PDA to be created" - ] - }, - { - "name": "systemProgram", - "isMut": false, - "isSigner": false, - "docs": [ - "System Program" - ] - }, - { - "name": "authorizerSigner", - "isMut": false, - "isSigner": true, - "isOptional": true, - "docs": [ - "Optional signer for Ed25519 authentication" - ] - } - ], - "args": [ - { - "name": "newType", - "type": "u8" - }, - { - "name": "newPubkey", - "type": { - "array": [ - "u8", - 33 - ] - } - }, - { - "name": "newHash", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "newRole", - "type": "u8" - } - ], - "discriminant": { - "type": "u8", - "value": 1 - } - }, - { - "name": "RemoveAuthority", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true, - "docs": [ - "Transaction payer" - ] - }, - { - "name": "wallet", - "isMut": false, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "adminAuthority", - "isMut": false, - "isSigner": true, - "docs": [ - "Admin authority PDA authorizing this action" - ] - }, - { - "name": "targetAuthority", - "isMut": true, - "isSigner": false, - "docs": [ - "Authority PDA to be removed" - ] - }, - { - "name": "refundDestination", - "isMut": true, - "isSigner": false, - "docs": [ - "Account to receive rent refund" - ] - }, - { - "name": "authorizerSigner", - "isMut": false, - "isSigner": true, - "isOptional": true, - "docs": [ - "Optional signer for Ed25519 authentication" - ] - } - ], - "args": [], - "discriminant": { - "type": "u8", - "value": 2 - } - }, - { - "name": "TransferOwnership", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true, - "docs": [ - "Transaction payer" - ] - }, - { - "name": "wallet", - "isMut": false, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "currentOwnerAuthority", - "isMut": true, - "isSigner": false, - "docs": [ - "Current owner authority PDA" - ] - }, - { - "name": "newOwnerAuthority", - "isMut": true, - "isSigner": false, - "docs": [ - "New owner authority PDA to be created" - ] - }, - { - "name": "systemProgram", - "isMut": false, - "isSigner": false, - "docs": [ - "System Program" - ] - }, - { - "name": "authorizerSigner", - "isMut": false, - "isSigner": true, - "isOptional": true, - "docs": [ - "Optional signer for Ed25519 authentication" - ] - } - ], - "args": [ - { - "name": "newType", - "type": "u8" - }, - { - "name": "newPubkey", - "type": { - "array": [ - "u8", - 33 - ] - } - }, - { - "name": "newHash", - "type": { - "array": [ - "u8", - 32 - ] - } - } - ], - "discriminant": { - "type": "u8", - "value": 3 - } - }, - { - "name": "Execute", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true, - "docs": [ - "Transaction payer" - ] - }, - { - "name": "wallet", - "isMut": false, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "authority", - "isMut": false, - "isSigner": false, - "docs": [ - "Authority or Session PDA authorizing execution" - ] - }, - { - "name": "vault", - "isMut": false, - "isSigner": false, - "docs": [ - "Vault PDA" - ] - }, - { - "name": "sysvarInstructions", - "isMut": false, - "isSigner": false, - "isOptional": true, - "docs": [ - "Sysvar Instructions (required for Secp256r1)" - ] - } - ], - "args": [ - { - "name": "instructions", - "type": "bytes" - } - ], - "discriminant": { - "type": "u8", - "value": 4 - } - }, - { - "name": "CreateSession", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true, - "docs": [ - "Transaction payer and rent contributor" - ] - }, - { - "name": "wallet", - "isMut": false, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "adminAuthority", - "isMut": false, - "isSigner": true, - "docs": [ - "Admin/Owner authority PDA authorizing logic" - ] - }, - { - "name": "session", - "isMut": true, - "isSigner": false, - "docs": [ - "New session PDA to be created" - ] - }, - { - "name": "systemProgram", - "isMut": false, - "isSigner": false, - "docs": [ - "System Program" - ] - }, - { - "name": "authorizerSigner", - "isMut": false, - "isSigner": true, - "isOptional": true, - "docs": [ - "Optional signer for Ed25519 authentication" - ] - } - ], - "args": [ - { - "name": "sessionKey", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "expiresAt", - "type": "i64" - } - ], - "discriminant": { - "type": "u8", - "value": 5 - } - } - ], - "metadata": { - "origin": "shank", - "address": "FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao" - }, - "accounts": [ - { - "name": "WalletAccount", - "type": { - "kind": "struct", - "fields": [ - { - "name": "discriminator", - "type": "u8" - }, - { - "name": "bump", - "type": "u8" - }, - { - "name": "version", - "type": "u8" - }, - { - "name": "padding", - "type": { - "array": [ - "u8", - 5 - ] - } - } - ] - } - }, - { - "name": "AuthorityAccount", - "type": { - "kind": "struct", - "fields": [ - { - "name": "discriminator", - "type": "u8" - }, - { - "name": "authorityType", - "type": "u8" - }, - { - "name": "role", - "type": "u8" - }, - { - "name": "bump", - "type": "u8" - }, - { - "name": "version", - "type": "u8" - }, - { - "name": "padding", - "type": { - "array": [ - "u8", - 3 - ] - } - }, - { - "name": "counter", - "type": "u64" - }, - { - "name": "wallet", - "type": "publicKey" - } - ] - } - }, - { - "name": "SessionAccount", - "type": { - "kind": "struct", - "fields": [ - { - "name": "discriminator", - "type": "u8" - }, - { - "name": "bump", - "type": "u8" - }, - { - "name": "version", - "type": "u8" - }, - { - "name": "padding", - "type": { - "array": [ - "u8", - 5 - ] - } - }, - { - "name": "wallet", - "type": "publicKey" - }, - { - "name": "sessionKey", - "type": "publicKey" - }, - { - "name": "expiresAt", - "type": "u64" - } - ] - } - } - ], - "errors": [ - { - "code": 3001, - "name": "InvalidAuthorityPayload", - "msg": "Invalid authority payload" - }, - { - "code": 3002, - "name": "PermissionDenied", - "msg": "Permission denied" - }, - { - "code": 3003, - "name": "InvalidInstruction", - "msg": "Invalid instruction" - }, - { - "code": 3004, - "name": "InvalidPubkey", - "msg": "Invalid public key" - }, - { - "code": 3005, - "name": "InvalidMessageHash", - "msg": "Invalid message hash" - }, - { - "code": 3006, - "name": "SignatureReused", - "msg": "Signature has already been used (counter mismatch)" - }, - { - "code": 3007, - "name": "InvalidSignatureAge", - "msg": "Signature too old (outside 150-slot window)" - }, - { - "code": 3008, - "name": "InvalidSessionDuration", - "msg": "Invalid session duration" - }, - { - "code": 3009, - "name": "SessionExpired", - "msg": "Session has expired" - }, - { - "code": 3010, - "name": "AuthorityDoesNotSupportSession", - "msg": "Authority type does not support sessions" - }, - { - "code": 3011, - "name": "InvalidAuthenticationKind", - "msg": "Invalid authentication kind" - }, - { - "code": 3012, - "name": "InvalidMessage", - "msg": "Invalid message" - }, - { - "code": 3013, - "name": "SelfReentrancyNotAllowed", - "msg": "Self-reentrancy is not allowed" - } - ], - "types": [ - { - "name": "AuthorityType", - "type": { - "kind": "enum", - "variants": [ - { - "name": "Ed25519" - }, - { - "name": "Secp256r1" - } - ] - } - }, - { - "name": "Role", - "type": { - "kind": "enum", - "variants": [ - { - "name": "Owner" - }, - { - "name": "Admin" - }, - { - "name": "Spender" - } - ] - } - }, - { - "name": "AccountDiscriminator", - "type": { - "kind": "enum", - "variants": [ - { - "name": "Uninitialized" - }, - { - "name": "Wallet" - }, - { - "name": "Authority" - }, - { - "name": "Session" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/sdk/solita-client/package-lock.json b/sdk/solita-client/package-lock.json deleted file mode 100644 index 9bccf38..0000000 --- a/sdk/solita-client/package-lock.json +++ /dev/null @@ -1,1577 +0,0 @@ -{ - "name": "@lazorkit/solita-client", - "version": "0.2.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@lazorkit/solita-client", - "version": "0.2.0", - "dependencies": { - "@metaplex-foundation/beet": "^0.7.2", - "@metaplex-foundation/beet-solana": "^0.4.1", - "@solana/web3.js": "^1.95.0" - }, - "devDependencies": { - "@metaplex-foundation/solita": "^0.20.1", - "typescript": "^5.9.3" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@metaplex-foundation/beet": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@metaplex-foundation/beet/-/beet-0.7.2.tgz", - "integrity": "sha512-K+g3WhyFxKPc0xIvcIjNyV1eaTVJTiuaHZpig7Xx0MuYRMoJLLvhLTnUXhFdR5Tu2l2QSyKwfyXDgZlzhULqFg==", - "license": "Apache-2.0", - "dependencies": { - "ansicolors": "^0.3.2", - "assert": "^2.1.0", - "bn.js": "^5.2.0", - "debug": "^4.3.3" - } - }, - "node_modules/@metaplex-foundation/beet-solana": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@metaplex-foundation/beet-solana/-/beet-solana-0.4.1.tgz", - "integrity": "sha512-/6o32FNUtwK8tjhotrvU/vorP7umBuRFvBZrC6XCk51aKidBHe5LPVPA5AjGPbV3oftMfRuXPNd9yAGeEqeCDQ==", - "license": "Apache-2.0", - "dependencies": { - "@metaplex-foundation/beet": ">=0.1.0", - "@solana/web3.js": "^1.56.2", - "bs58": "^5.0.0", - "debug": "^4.3.4" - } - }, - "node_modules/@metaplex-foundation/rustbin": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@metaplex-foundation/rustbin/-/rustbin-0.3.5.tgz", - "integrity": "sha512-m0wkRBEQB/8krwMwKBvFugufZtYwMXiGHud2cTDAv+aGXK4M90y0Hx67/wpu+AqqoQfdV8VM9YezUOHKD+Z5kA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.3.3", - "semver": "^7.3.7", - "text-table": "^0.2.0", - "toml": "^3.0.0" - } - }, - "node_modules/@metaplex-foundation/solita": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@metaplex-foundation/solita/-/solita-0.20.1.tgz", - "integrity": "sha512-E2bHGzT6wA/sXWBLgJ50ZQNvukPnQlH6kRU6m6lmatJdEOjNWhR1lLI7ESIk/i4ZiSdHZkc/Q6ile8eIlXOzNQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@metaplex-foundation/beet": "^0.7.1", - "@metaplex-foundation/beet-solana": "^0.3.1", - "@metaplex-foundation/rustbin": "^0.3.0", - "@solana/web3.js": "^1.56.2", - "ansi-colors": "^4.1.3", - "camelcase": "^6.2.1", - "debug": "^4.3.3", - "js-sha256": "^0.9.0", - "prettier": "^2.5.1", - "snake-case": "^3.0.4", - "spok": "^1.4.3" - }, - "bin": { - "solita": "dist/src/cli/solita.js" - } - }, - "node_modules/@metaplex-foundation/solita/node_modules/@metaplex-foundation/beet-solana": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@metaplex-foundation/beet-solana/-/beet-solana-0.3.1.tgz", - "integrity": "sha512-tgyEl6dvtLln8XX81JyBvWjIiEcjTkUwZbrM5dIobTmoqMuGewSyk9CClno8qsMsFdB5T3jC91Rjeqmu/6xk2g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@metaplex-foundation/beet": ">=0.1.0", - "@solana/web3.js": "^1.56.2", - "bs58": "^5.0.0", - "debug": "^4.3.4" - } - }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@solana/buffer-layout": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz", - "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==", - "license": "MIT", - "dependencies": { - "buffer": "~6.0.3" - }, - "engines": { - "node": ">=5.10" - } - }, - "node_modules/@solana/codecs-core": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz", - "integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==", - "license": "MIT", - "dependencies": { - "@solana/errors": "2.3.0" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/codecs-numbers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz", - "integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==", - "license": "MIT", - "dependencies": { - "@solana/codecs-core": "2.3.0", - "@solana/errors": "2.3.0" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/errors": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz", - "integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==", - "license": "MIT", - "dependencies": { - "chalk": "^5.4.1", - "commander": "^14.0.0" - }, - "bin": { - "errors": "bin/cli.mjs" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/web3.js": { - "version": "1.98.4", - "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", - "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.0", - "@noble/curves": "^1.4.2", - "@noble/hashes": "^1.4.0", - "@solana/buffer-layout": "^4.0.1", - "@solana/codecs-numbers": "^2.1.0", - "agentkeepalive": "^4.5.0", - "bn.js": "^5.2.1", - "borsh": "^0.7.0", - "bs58": "^4.0.1", - "buffer": "6.0.3", - "fast-stable-stringify": "^1.0.0", - "jayson": "^4.1.1", - "node-fetch": "^2.7.0", - "rpc-websockets": "^9.0.2", - "superstruct": "^2.0.2" - } - }, - "node_modules/@solana/web3.js/node_modules/base-x": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", - "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@solana/web3.js/node_modules/bs58": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", - "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", - "license": "MIT", - "dependencies": { - "base-x": "^3.0.2" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "license": "MIT" - }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", - "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ansicolors": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", - "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", - "license": "MIT" - }, - "node_modules/assert": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", - "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "is-nan": "^1.3.2", - "object-is": "^1.1.5", - "object.assign": "^4.1.4", - "util": "^0.12.5" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/base-x": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", - "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bn.js": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", - "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", - "license": "MIT" - }, - "node_modules/borsh": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", - "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", - "license": "Apache-2.0", - "dependencies": { - "bn.js": "^5.2.0", - "bs58": "^4.0.0", - "text-encoding-utf-8": "^1.0.2" - } - }, - "node_modules/borsh/node_modules/base-x": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", - "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/borsh/node_modules/bs58": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", - "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", - "license": "MIT", - "dependencies": { - "base-x": "^3.0.2" - } - }, - "node_modules/bs58": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", - "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", - "license": "MIT", - "dependencies": { - "base-x": "^4.0.0" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "license": "MIT", - "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" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delay": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", - "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es6-promise": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", - "license": "MIT" - }, - "node_modules/es6-promisify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", - "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", - "license": "MIT", - "dependencies": { - "es6-promise": "^4.0.3" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, - "node_modules/eyes": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", - "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", - "engines": { - "node": "> 0.1.90" - } - }, - "node_modules/fast-stable-stringify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", - "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", - "license": "MIT" - }, - "node_modules/find-process": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/find-process/-/find-process-1.4.11.tgz", - "integrity": "sha512-mAOh9gGk9WZ4ip5UjV0o6Vb4SrfnAmtsFNzkMRH9HQiFXVQnDyQFrSHTK5UoG6E+KV+s+cIznbtwpfN41l2nFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "~4.1.2", - "commander": "^12.1.0", - "loglevel": "^1.9.2" - }, - "bin": { - "find-process": "bin/find-process.js" - } - }, - "node_modules/find-process/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/find-process/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-nan": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", - "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isomorphic-ws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", - "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", - "license": "MIT", - "peerDependencies": { - "ws": "*" - } - }, - "node_modules/jayson": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz", - "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==", - "license": "MIT", - "dependencies": { - "@types/connect": "^3.4.33", - "@types/node": "^12.12.54", - "@types/ws": "^7.4.4", - "commander": "^2.20.3", - "delay": "^5.0.0", - "es6-promisify": "^5.0.0", - "eyes": "^0.1.8", - "isomorphic-ws": "^4.0.1", - "json-stringify-safe": "^5.0.1", - "stream-json": "^1.9.1", - "uuid": "^8.3.2", - "ws": "^7.5.10" - }, - "bin": { - "jayson": "bin/jayson.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jayson/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/js-sha256": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", - "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" - }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "optional": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/rpc-websockets": { - "version": "9.3.8", - "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.8.tgz", - "integrity": "sha512-7r+fm4tSJmLf9GvZfL1DJ1SJwpagpp6AazqM0FUaeV7CA+7+NYINSk1syWa4tU/6OF2CyBicLtzENGmXRJH6wQ==", - "license": "LGPL-3.0-only", - "dependencies": { - "@swc/helpers": "^0.5.11", - "@types/uuid": "^10.0.0", - "@types/ws": "^8.2.2", - "buffer": "^6.0.3", - "eventemitter3": "^5.0.1", - "uuid": "^11.0.0", - "ws": "^8.5.0" - }, - "funding": { - "type": "paypal", - "url": "https://paypal.me/kozjak" - }, - "optionalDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^6.0.0" - } - }, - "node_modules/rpc-websockets/node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/rpc-websockets/node_modules/utf-8-validate": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", - "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/rpc-websockets/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/rpc-websockets/node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", - "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 - } - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/spok": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/spok/-/spok-1.5.5.tgz", - "integrity": "sha512-IrJIXY54sCNFASyHPOY+jEirkiJ26JDqsGiI0Dvhwcnkl0PEWi1PSsrkYql0rzDw8LFVTcA7rdUCAJdE2HE+2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansicolors": "~0.3.2", - "find-process": "^1.4.7" - } - }, - "node_modules/stream-chain": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", - "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", - "license": "BSD-3-Clause" - }, - "node_modules/stream-json": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", - "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", - "license": "BSD-3-Clause", - "dependencies": { - "stream-chain": "^2.2.5" - } - }, - "node_modules/superstruct": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", - "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/text-encoding-utf-8": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz", - "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==" - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "license": "MIT", - "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" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/sdk/solita-client/package.json b/sdk/solita-client/package.json deleted file mode 100644 index c83a671..0000000 --- a/sdk/solita-client/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@lazorkit/solita-client", - "version": "0.2.0", - "description": "LazorKit Smart Wallet TypeScript SDK (Solita-generated + hand-written utils)", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "generate": "node generate.mjs", - "build": "tsc", - "clean": "rm -rf dist" - }, - "dependencies": { - "@solana/web3.js": "^1.95.0", - "@metaplex-foundation/beet": "^0.7.2", - "@metaplex-foundation/beet-solana": "^0.4.1" - }, - "devDependencies": { - "@metaplex-foundation/solita": "^0.20.1", - "typescript": "^5.9.3" - } -} diff --git a/sdk/solita-client/src/generated/accounts/AuthorityAccount.ts b/sdk/solita-client/src/generated/accounts/AuthorityAccount.ts deleted file mode 100644 index 4c51ffb..0000000 --- a/sdk/solita-client/src/generated/accounts/AuthorityAccount.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; -import * as beetSolana from '@metaplex-foundation/beet-solana'; - - - - -/** - * Arguments used to create {@link AuthorityAccount} - * @category Accounts - * @category generated - */ -export type AuthorityAccountArgs = { - discriminator: number - authorityType: number - role: number - bump: number - version: number - padding: number[] /* size: 3 */ - counter: beet.bignum - wallet: web3.PublicKey -} - -; -/** - * Holds the data for the {@link AuthorityAccount} Account and provides de/serialization - * functionality for that data - * - * @category Accounts - * @category generated - */ -export class AuthorityAccount implements AuthorityAccountArgs { - private constructor( - readonly discriminator: number, - readonly authorityType: number, - readonly role: number, - readonly bump: number, - readonly version: number, - readonly padding: number[] /* size: 3 */, - readonly counter: beet.bignum, - readonly wallet: web3.PublicKey - ) {} - - /** - * Creates a {@link AuthorityAccount} instance from the provided args. - */ - static fromArgs(args: AuthorityAccountArgs) { - return new AuthorityAccount( - args.discriminator, - args.authorityType, - args.role, - args.bump, - args.version, - args.padding, - args.counter, - args.wallet - ); - } - - /** - * Deserializes the {@link AuthorityAccount} from the data of the provided {@link web3.AccountInfo}. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static fromAccountInfo( - accountInfo: web3.AccountInfo, - offset = 0 - ): [ AuthorityAccount, number ] { - return AuthorityAccount.deserialize(accountInfo.data, offset) - } - - /** - * Retrieves the account info from the provided address and deserializes - * the {@link AuthorityAccount} from its data. - * - * @throws Error if no account info is found at the address or if deserialization fails - */ - static async fromAccountAddress( - connection: web3.Connection, - address: web3.PublicKey, - commitmentOrConfig?: web3.Commitment | web3.GetAccountInfoConfig, - ): Promise { - const accountInfo = await connection.getAccountInfo(address, commitmentOrConfig); - if (accountInfo == null) { - throw new Error(`Unable to find AuthorityAccount account at ${address}`); - } - return AuthorityAccount.fromAccountInfo(accountInfo, 0)[0]; - } - - - /** - * Provides a {@link web3.Connection.getProgramAccounts} config builder, - * to fetch accounts matching filters that can be specified via that builder. - * - * @param programId - the program that owns the accounts we are filtering - */ - static gpaBuilder(programId: web3.PublicKey = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao')) { - return beetSolana.GpaBuilder.fromStruct(programId, authorityAccountBeet) - } - - /** - * Deserializes the {@link AuthorityAccount} from the provided data Buffer. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static deserialize( - buf: Buffer, - offset = 0 - ): [ AuthorityAccount, number ]{ - return authorityAccountBeet.deserialize(buf, offset); - } - - /** - * Serializes the {@link AuthorityAccount} into a Buffer. - * @returns a tuple of the created Buffer and the offset up to which the buffer was written to store it. - */ - serialize(): [ Buffer, number ] { - return authorityAccountBeet.serialize(this) - } - - /** - * Returns the byteSize of a {@link Buffer} holding the serialized data of - * {@link AuthorityAccount} - */ - static get byteSize() { - return authorityAccountBeet.byteSize; - } - - /** - * Fetches the minimum balance needed to exempt an account holding - * {@link AuthorityAccount} data from rent - * - * @param connection used to retrieve the rent exemption information - */ - static async getMinimumBalanceForRentExemption( - connection: web3.Connection, - commitment?: web3.Commitment, - ): Promise { - return connection.getMinimumBalanceForRentExemption( - AuthorityAccount.byteSize, - commitment, - ); - } - - /** - * Determines if the provided {@link Buffer} has the correct byte size to - * hold {@link AuthorityAccount} data. - */ - static hasCorrectByteSize(buf: Buffer, offset = 0) { - return buf.byteLength - offset === AuthorityAccount.byteSize; - } - - /** - * Returns a readable version of {@link AuthorityAccount} properties - * and can be used to convert to JSON and/or logging - */ - pretty() { - return { - discriminator: this.discriminator, - authorityType: this.authorityType, - role: this.role, - bump: this.bump, - version: this.version, - padding: this.padding, - counter: (() => { - const x = <{ toNumber: () => number }>this.counter - if (typeof x.toNumber === 'function') { - try { - return x.toNumber() - } catch (_) { return x } - } - return x - })(), - wallet: this.wallet.toBase58() - }; - } -} - -/** - * @category Accounts - * @category generated - */ -export const authorityAccountBeet = new beet.BeetStruct< - AuthorityAccount, - AuthorityAccountArgs ->( - [ - - ['discriminator', beet.u8], - ['authorityType', beet.u8], - ['role', beet.u8], - ['bump', beet.u8], - ['version', beet.u8], - ['padding', beet.uniformFixedSizeArray(beet.u8, 3)], - ['counter', beet.u64], - ['wallet', beetSolana.publicKey] - ], - AuthorityAccount.fromArgs, - 'AuthorityAccount' -) \ No newline at end of file diff --git a/sdk/solita-client/src/generated/accounts/SessionAccount.ts b/sdk/solita-client/src/generated/accounts/SessionAccount.ts deleted file mode 100644 index e1efaca..0000000 --- a/sdk/solita-client/src/generated/accounts/SessionAccount.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as web3 from '@solana/web3.js'; -import * as beet from '@metaplex-foundation/beet'; -import * as beetSolana from '@metaplex-foundation/beet-solana'; - - - - -/** - * Arguments used to create {@link SessionAccount} - * @category Accounts - * @category generated - */ -export type SessionAccountArgs = { - discriminator: number - bump: number - version: number - padding: number[] /* size: 5 */ - wallet: web3.PublicKey - sessionKey: web3.PublicKey - expiresAt: beet.bignum -} - -; -/** - * Holds the data for the {@link SessionAccount} Account and provides de/serialization - * functionality for that data - * - * @category Accounts - * @category generated - */ -export class SessionAccount implements SessionAccountArgs { - private constructor( - readonly discriminator: number, - readonly bump: number, - readonly version: number, - readonly padding: number[] /* size: 5 */, - readonly wallet: web3.PublicKey, - readonly sessionKey: web3.PublicKey, - readonly expiresAt: beet.bignum - ) {} - - /** - * Creates a {@link SessionAccount} instance from the provided args. - */ - static fromArgs(args: SessionAccountArgs) { - return new SessionAccount( - args.discriminator, - args.bump, - args.version, - args.padding, - args.wallet, - args.sessionKey, - args.expiresAt - ); - } - - /** - * Deserializes the {@link SessionAccount} from the data of the provided {@link web3.AccountInfo}. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static fromAccountInfo( - accountInfo: web3.AccountInfo, - offset = 0 - ): [ SessionAccount, number ] { - return SessionAccount.deserialize(accountInfo.data, offset) - } - - /** - * Retrieves the account info from the provided address and deserializes - * the {@link SessionAccount} from its data. - * - * @throws Error if no account info is found at the address or if deserialization fails - */ - static async fromAccountAddress( - connection: web3.Connection, - address: web3.PublicKey, - commitmentOrConfig?: web3.Commitment | web3.GetAccountInfoConfig, - ): Promise { - const accountInfo = await connection.getAccountInfo(address, commitmentOrConfig); - if (accountInfo == null) { - throw new Error(`Unable to find SessionAccount account at ${address}`); - } - return SessionAccount.fromAccountInfo(accountInfo, 0)[0]; - } - - - /** - * Provides a {@link web3.Connection.getProgramAccounts} config builder, - * to fetch accounts matching filters that can be specified via that builder. - * - * @param programId - the program that owns the accounts we are filtering - */ - static gpaBuilder(programId: web3.PublicKey = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao')) { - return beetSolana.GpaBuilder.fromStruct(programId, sessionAccountBeet) - } - - /** - * Deserializes the {@link SessionAccount} from the provided data Buffer. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static deserialize( - buf: Buffer, - offset = 0 - ): [ SessionAccount, number ]{ - return sessionAccountBeet.deserialize(buf, offset); - } - - /** - * Serializes the {@link SessionAccount} into a Buffer. - * @returns a tuple of the created Buffer and the offset up to which the buffer was written to store it. - */ - serialize(): [ Buffer, number ] { - return sessionAccountBeet.serialize(this) - } - - /** - * Returns the byteSize of a {@link Buffer} holding the serialized data of - * {@link SessionAccount} - */ - static get byteSize() { - return sessionAccountBeet.byteSize; - } - - /** - * Fetches the minimum balance needed to exempt an account holding - * {@link SessionAccount} data from rent - * - * @param connection used to retrieve the rent exemption information - */ - static async getMinimumBalanceForRentExemption( - connection: web3.Connection, - commitment?: web3.Commitment, - ): Promise { - return connection.getMinimumBalanceForRentExemption( - SessionAccount.byteSize, - commitment, - ); - } - - /** - * Determines if the provided {@link Buffer} has the correct byte size to - * hold {@link SessionAccount} data. - */ - static hasCorrectByteSize(buf: Buffer, offset = 0) { - return buf.byteLength - offset === SessionAccount.byteSize; - } - - /** - * Returns a readable version of {@link SessionAccount} properties - * and can be used to convert to JSON and/or logging - */ - pretty() { - return { - discriminator: this.discriminator, - bump: this.bump, - version: this.version, - padding: this.padding, - wallet: this.wallet.toBase58(), - sessionKey: this.sessionKey.toBase58(), - expiresAt: (() => { - const x = <{ toNumber: () => number }>this.expiresAt - if (typeof x.toNumber === 'function') { - try { - return x.toNumber() - } catch (_) { return x } - } - return x - })() - }; - } -} - -/** - * @category Accounts - * @category generated - */ -export const sessionAccountBeet = new beet.BeetStruct< - SessionAccount, - SessionAccountArgs ->( - [ - - ['discriminator', beet.u8], - ['bump', beet.u8], - ['version', beet.u8], - ['padding', beet.uniformFixedSizeArray(beet.u8, 5)], - ['wallet', beetSolana.publicKey], - ['sessionKey', beetSolana.publicKey], - ['expiresAt', beet.u64] - ], - SessionAccount.fromArgs, - 'SessionAccount' -) \ No newline at end of file diff --git a/sdk/solita-client/src/generated/accounts/WalletAccount.ts b/sdk/solita-client/src/generated/accounts/WalletAccount.ts deleted file mode 100644 index d2af9eb..0000000 --- a/sdk/solita-client/src/generated/accounts/WalletAccount.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; -import * as beetSolana from '@metaplex-foundation/beet-solana'; - - - - -/** - * Arguments used to create {@link WalletAccount} - * @category Accounts - * @category generated - */ -export type WalletAccountArgs = { - discriminator: number - bump: number - version: number - padding: number[] /* size: 5 */ -} - -; -/** - * Holds the data for the {@link WalletAccount} Account and provides de/serialization - * functionality for that data - * - * @category Accounts - * @category generated - */ -export class WalletAccount implements WalletAccountArgs { - private constructor( - readonly discriminator: number, - readonly bump: number, - readonly version: number, - readonly padding: number[] /* size: 5 */ - ) {} - - /** - * Creates a {@link WalletAccount} instance from the provided args. - */ - static fromArgs(args: WalletAccountArgs) { - return new WalletAccount( - args.discriminator, - args.bump, - args.version, - args.padding - ); - } - - /** - * Deserializes the {@link WalletAccount} from the data of the provided {@link web3.AccountInfo}. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static fromAccountInfo( - accountInfo: web3.AccountInfo, - offset = 0 - ): [ WalletAccount, number ] { - return WalletAccount.deserialize(accountInfo.data, offset) - } - - /** - * Retrieves the account info from the provided address and deserializes - * the {@link WalletAccount} from its data. - * - * @throws Error if no account info is found at the address or if deserialization fails - */ - static async fromAccountAddress( - connection: web3.Connection, - address: web3.PublicKey, - commitmentOrConfig?: web3.Commitment | web3.GetAccountInfoConfig, - ): Promise { - const accountInfo = await connection.getAccountInfo(address, commitmentOrConfig); - if (accountInfo == null) { - throw new Error(`Unable to find WalletAccount account at ${address}`); - } - return WalletAccount.fromAccountInfo(accountInfo, 0)[0]; - } - - - /** - * Provides a {@link web3.Connection.getProgramAccounts} config builder, - * to fetch accounts matching filters that can be specified via that builder. - * - * @param programId - the program that owns the accounts we are filtering - */ - static gpaBuilder(programId: web3.PublicKey = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao')) { - return beetSolana.GpaBuilder.fromStruct(programId, walletAccountBeet) - } - - /** - * Deserializes the {@link WalletAccount} from the provided data Buffer. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static deserialize( - buf: Buffer, - offset = 0 - ): [ WalletAccount, number ]{ - return walletAccountBeet.deserialize(buf, offset); - } - - /** - * Serializes the {@link WalletAccount} into a Buffer. - * @returns a tuple of the created Buffer and the offset up to which the buffer was written to store it. - */ - serialize(): [ Buffer, number ] { - return walletAccountBeet.serialize(this) - } - - /** - * Returns the byteSize of a {@link Buffer} holding the serialized data of - * {@link WalletAccount} - */ - static get byteSize() { - return walletAccountBeet.byteSize; - } - - /** - * Fetches the minimum balance needed to exempt an account holding - * {@link WalletAccount} data from rent - * - * @param connection used to retrieve the rent exemption information - */ - static async getMinimumBalanceForRentExemption( - connection: web3.Connection, - commitment?: web3.Commitment, - ): Promise { - return connection.getMinimumBalanceForRentExemption( - WalletAccount.byteSize, - commitment, - ); - } - - /** - * Determines if the provided {@link Buffer} has the correct byte size to - * hold {@link WalletAccount} data. - */ - static hasCorrectByteSize(buf: Buffer, offset = 0) { - return buf.byteLength - offset === WalletAccount.byteSize; - } - - /** - * Returns a readable version of {@link WalletAccount} properties - * and can be used to convert to JSON and/or logging - */ - pretty() { - return { - discriminator: this.discriminator, - bump: this.bump, - version: this.version, - padding: this.padding - }; - } -} - -/** - * @category Accounts - * @category generated - */ -export const walletAccountBeet = new beet.BeetStruct< - WalletAccount, - WalletAccountArgs ->( - [ - - ['discriminator', beet.u8], - ['bump', beet.u8], - ['version', beet.u8], - ['padding', beet.uniformFixedSizeArray(beet.u8, 5)] - ], - WalletAccount.fromArgs, - 'WalletAccount' -) \ No newline at end of file diff --git a/sdk/solita-client/src/generated/accounts/index.ts b/sdk/solita-client/src/generated/accounts/index.ts deleted file mode 100644 index a358a94..0000000 --- a/sdk/solita-client/src/generated/accounts/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './AuthorityAccount'; -export * from './SessionAccount'; -export * from './WalletAccount'; - -import { WalletAccount } from './WalletAccount' -import { AuthorityAccount } from './AuthorityAccount' -import { SessionAccount } from './SessionAccount' - -export const accountProviders = { WalletAccount, AuthorityAccount, SessionAccount } \ No newline at end of file diff --git a/sdk/solita-client/src/generated/errors/index.ts b/sdk/solita-client/src/generated/errors/index.ts deleted file mode 100644 index 596c4e4..0000000 --- a/sdk/solita-client/src/generated/errors/index.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -type ErrorWithCode = Error & { code: number } -type MaybeErrorWithCode = ErrorWithCode | null | undefined - -const createErrorFromCodeLookup: Map ErrorWithCode> = new Map(); -const createErrorFromNameLookup: Map ErrorWithCode> = new Map(); - - -/** - * InvalidAuthorityPayload: 'Invalid authority payload' - * - * @category Errors - * @category generated - */ -export class InvalidAuthorityPayloadError extends Error { - readonly code: number = 0xbb9; - readonly name: string = 'InvalidAuthorityPayload'; - constructor() { - super('Invalid authority payload'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidAuthorityPayloadError); - } - } -} - -createErrorFromCodeLookup.set(0xbb9, () => new InvalidAuthorityPayloadError()) -createErrorFromNameLookup.set('InvalidAuthorityPayload', () => new InvalidAuthorityPayloadError()) - - - -/** - * PermissionDenied: 'Permission denied' - * - * @category Errors - * @category generated - */ -export class PermissionDeniedError extends Error { - readonly code: number = 0xbba; - readonly name: string = 'PermissionDenied'; - constructor() { - super('Permission denied'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, PermissionDeniedError); - } - } -} - -createErrorFromCodeLookup.set(0xbba, () => new PermissionDeniedError()) -createErrorFromNameLookup.set('PermissionDenied', () => new PermissionDeniedError()) - - - -/** - * InvalidInstruction: 'Invalid instruction' - * - * @category Errors - * @category generated - */ -export class InvalidInstructionError extends Error { - readonly code: number = 0xbbb; - readonly name: string = 'InvalidInstruction'; - constructor() { - super('Invalid instruction'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidInstructionError); - } - } -} - -createErrorFromCodeLookup.set(0xbbb, () => new InvalidInstructionError()) -createErrorFromNameLookup.set('InvalidInstruction', () => new InvalidInstructionError()) - - - -/** - * InvalidPubkey: 'Invalid public key' - * - * @category Errors - * @category generated - */ -export class InvalidPubkeyError extends Error { - readonly code: number = 0xbbc; - readonly name: string = 'InvalidPubkey'; - constructor() { - super('Invalid public key'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidPubkeyError); - } - } -} - -createErrorFromCodeLookup.set(0xbbc, () => new InvalidPubkeyError()) -createErrorFromNameLookup.set('InvalidPubkey', () => new InvalidPubkeyError()) - - - -/** - * InvalidMessageHash: 'Invalid message hash' - * - * @category Errors - * @category generated - */ -export class InvalidMessageHashError extends Error { - readonly code: number = 0xbbd; - readonly name: string = 'InvalidMessageHash'; - constructor() { - super('Invalid message hash'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidMessageHashError); - } - } -} - -createErrorFromCodeLookup.set(0xbbd, () => new InvalidMessageHashError()) -createErrorFromNameLookup.set('InvalidMessageHash', () => new InvalidMessageHashError()) - - - -/** - * SignatureReused: 'Signature has already been used (counter mismatch)' - * - * @category Errors - * @category generated - */ -export class SignatureReusedError extends Error { - readonly code: number = 0xbbe; - readonly name: string = 'SignatureReused'; - constructor() { - super('Signature has already been used (counter mismatch)'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, SignatureReusedError); - } - } -} - -createErrorFromCodeLookup.set(0xbbe, () => new SignatureReusedError()) -createErrorFromNameLookup.set('SignatureReused', () => new SignatureReusedError()) - - - -/** - * InvalidSignatureAge: 'Signature too old (outside 150-slot window)' - * - * @category Errors - * @category generated - */ -export class InvalidSignatureAgeError extends Error { - readonly code: number = 0xbbf; - readonly name: string = 'InvalidSignatureAge'; - constructor() { - super('Signature too old (outside 150-slot window)'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidSignatureAgeError); - } - } -} - -createErrorFromCodeLookup.set(0xbbf, () => new InvalidSignatureAgeError()) -createErrorFromNameLookup.set('InvalidSignatureAge', () => new InvalidSignatureAgeError()) - - - -/** - * InvalidSessionDuration: 'Invalid session duration' - * - * @category Errors - * @category generated - */ -export class InvalidSessionDurationError extends Error { - readonly code: number = 0xbc0; - readonly name: string = 'InvalidSessionDuration'; - constructor() { - super('Invalid session duration'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidSessionDurationError); - } - } -} - -createErrorFromCodeLookup.set(0xbc0, () => new InvalidSessionDurationError()) -createErrorFromNameLookup.set('InvalidSessionDuration', () => new InvalidSessionDurationError()) - - - -/** - * SessionExpired: 'Session has expired' - * - * @category Errors - * @category generated - */ -export class SessionExpiredError extends Error { - readonly code: number = 0xbc1; - readonly name: string = 'SessionExpired'; - constructor() { - super('Session has expired'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, SessionExpiredError); - } - } -} - -createErrorFromCodeLookup.set(0xbc1, () => new SessionExpiredError()) -createErrorFromNameLookup.set('SessionExpired', () => new SessionExpiredError()) - - - -/** - * AuthorityDoesNotSupportSession: 'Authority type does not support sessions' - * - * @category Errors - * @category generated - */ -export class AuthorityDoesNotSupportSessionError extends Error { - readonly code: number = 0xbc2; - readonly name: string = 'AuthorityDoesNotSupportSession'; - constructor() { - super('Authority type does not support sessions'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, AuthorityDoesNotSupportSessionError); - } - } -} - -createErrorFromCodeLookup.set(0xbc2, () => new AuthorityDoesNotSupportSessionError()) -createErrorFromNameLookup.set('AuthorityDoesNotSupportSession', () => new AuthorityDoesNotSupportSessionError()) - - - -/** - * InvalidAuthenticationKind: 'Invalid authentication kind' - * - * @category Errors - * @category generated - */ -export class InvalidAuthenticationKindError extends Error { - readonly code: number = 0xbc3; - readonly name: string = 'InvalidAuthenticationKind'; - constructor() { - super('Invalid authentication kind'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidAuthenticationKindError); - } - } -} - -createErrorFromCodeLookup.set(0xbc3, () => new InvalidAuthenticationKindError()) -createErrorFromNameLookup.set('InvalidAuthenticationKind', () => new InvalidAuthenticationKindError()) - - - -/** - * InvalidMessage: 'Invalid message' - * - * @category Errors - * @category generated - */ -export class InvalidMessageError extends Error { - readonly code: number = 0xbc4; - readonly name: string = 'InvalidMessage'; - constructor() { - super('Invalid message'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidMessageError); - } - } -} - -createErrorFromCodeLookup.set(0xbc4, () => new InvalidMessageError()) -createErrorFromNameLookup.set('InvalidMessage', () => new InvalidMessageError()) - - - -/** - * SelfReentrancyNotAllowed: 'Self-reentrancy is not allowed' - * - * @category Errors - * @category generated - */ -export class SelfReentrancyNotAllowedError extends Error { - readonly code: number = 0xbc5; - readonly name: string = 'SelfReentrancyNotAllowed'; - constructor() { - super('Self-reentrancy is not allowed'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, SelfReentrancyNotAllowedError); - } - } -} - -createErrorFromCodeLookup.set(0xbc5, () => new SelfReentrancyNotAllowedError()) -createErrorFromNameLookup.set('SelfReentrancyNotAllowed', () => new SelfReentrancyNotAllowedError()) - - -/** - * Attempts to resolve a custom program error from the provided error code. - * @category Errors - * @category generated - */ -export function errorFromCode(code: number): MaybeErrorWithCode { - const createError = createErrorFromCodeLookup.get(code) - return createError != null ? createError() : null; -} - -/** - * Attempts to resolve a custom program error from the provided error name, i.e. 'Unauthorized'. - * @category Errors - * @category generated - */ -export function errorFromName(name: string): MaybeErrorWithCode { - const createError = createErrorFromNameLookup.get(name) - return createError != null ? createError() : null; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/index.ts b/sdk/solita-client/src/generated/index.ts deleted file mode 100644 index d1ed2fe..0000000 --- a/sdk/solita-client/src/generated/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { PublicKey } from '@solana/web3.js' -export * from './accounts'; -export * from './errors'; -export * from './instructions'; -export * from './types'; - -/** - * Program address - * - * @category constants - * @category generated - */ -export const PROGRAM_ADDRESS = 'FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao' - -/** - * Program public key - * - * @category constants - * @category generated - */ -export const PROGRAM_ID = new PublicKey(PROGRAM_ADDRESS) \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/AddAuthority.ts b/sdk/solita-client/src/generated/instructions/AddAuthority.ts deleted file mode 100644 index e8ce77d..0000000 --- a/sdk/solita-client/src/generated/instructions/AddAuthority.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - -/** - * @category Instructions - * @category AddAuthority - * @category generated - */ -export type AddAuthorityInstructionArgs = { - newType: number, - newPubkey: number[] /* size: 33 */, - newHash: number[] /* size: 32 */, - newRole: number -} -/** - * @category Instructions - * @category AddAuthority - * @category generated - */ -export const AddAuthorityStruct = new beet.BeetArgsStruct( - [ - ['instructionDiscriminator', beet.u8], - ['newType', beet.u8], - ['newPubkey', beet.uniformFixedSizeArray(beet.u8, 33)], - ['newHash', beet.uniformFixedSizeArray(beet.u8, 32)], - ['newRole', beet.u8] - ], - 'AddAuthorityInstructionArgs' -) -/** - * Accounts required by the _AddAuthority_ instruction - * - * @property [**signer**] payer -* @property [] wallet -* @property [**signer**] adminAuthority -* @property [_writable_] newAuthority -* @property [**signer**] authorizerSigner (optional) - * @category Instructions - * @category AddAuthority - * @category generated - */ - export type AddAuthorityInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - adminAuthority: web3.PublicKey - newAuthority: web3.PublicKey - systemProgram?: web3.PublicKey - authorizerSigner?: web3.PublicKey - - } - - export const addAuthorityInstructionDiscriminator = 1; - - /** - * Creates a _AddAuthority_ instruction. - * - * Optional accounts that are not provided default to the program ID since - * this was indicated in the IDL from which this instruction was generated. - * - * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * - * @category Instructions - * @category AddAuthority - * @category generated - */ - export function createAddAuthorityInstruction( - accounts: AddAuthorityInstructionAccounts, -args: AddAuthorityInstructionArgs , programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = AddAuthorityStruct.serialize({ - instructionDiscriminator: addAuthorityInstructionDiscriminator, - ...args - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.adminAuthority, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.newAuthority, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.authorizerSigner ?? programId, - isWritable: false, - isSigner: accounts.authorizerSigner != null, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/CreateSession.ts b/sdk/solita-client/src/generated/instructions/CreateSession.ts deleted file mode 100644 index 384450d..0000000 --- a/sdk/solita-client/src/generated/instructions/CreateSession.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - -/** - * @category Instructions - * @category CreateSession - * @category generated - */ -export type CreateSessionInstructionArgs = { - sessionKey: number[] /* size: 32 */, - expiresAt: beet.bignum -} -/** - * @category Instructions - * @category CreateSession - * @category generated - */ -export const CreateSessionStruct = new beet.BeetArgsStruct( - [ - ['instructionDiscriminator', beet.u8], - ['sessionKey', beet.uniformFixedSizeArray(beet.u8, 32)], - ['expiresAt', beet.i64] - ], - 'CreateSessionInstructionArgs' -) -/** - * Accounts required by the _CreateSession_ instruction - * - * @property [**signer**] payer -* @property [] wallet -* @property [**signer**] adminAuthority -* @property [_writable_] session -* @property [**signer**] authorizerSigner (optional) - * @category Instructions - * @category CreateSession - * @category generated - */ - export type CreateSessionInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - adminAuthority: web3.PublicKey - session: web3.PublicKey - systemProgram?: web3.PublicKey - authorizerSigner?: web3.PublicKey - - } - - export const createSessionInstructionDiscriminator = 5; - - /** - * Creates a _CreateSession_ instruction. - * - * Optional accounts that are not provided default to the program ID since - * this was indicated in the IDL from which this instruction was generated. - * - * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * - * @category Instructions - * @category CreateSession - * @category generated - */ - export function createCreateSessionInstruction( - accounts: CreateSessionInstructionAccounts, -args: CreateSessionInstructionArgs , programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = CreateSessionStruct.serialize({ - instructionDiscriminator: createSessionInstructionDiscriminator, - ...args - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.adminAuthority, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.session, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.authorizerSigner ?? programId, - isWritable: false, - isSigner: accounts.authorizerSigner != null, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/CreateWallet.ts b/sdk/solita-client/src/generated/instructions/CreateWallet.ts deleted file mode 100644 index b4853e5..0000000 --- a/sdk/solita-client/src/generated/instructions/CreateWallet.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - -/** - * @category Instructions - * @category CreateWallet - * @category generated - */ -export type CreateWalletInstructionArgs = { - userSeed: Uint8Array, - authType: number, - authPubkey: number[] /* size: 33 */, - credentialHash: number[] /* size: 32 */ -} -/** - * @category Instructions - * @category CreateWallet - * @category generated - */ -export const CreateWalletStruct = new beet.FixableBeetArgsStruct( - [ - ['instructionDiscriminator', beet.u8], - ['userSeed', beet.bytes], - ['authType', beet.u8], - ['authPubkey', beet.uniformFixedSizeArray(beet.u8, 33)], - ['credentialHash', beet.uniformFixedSizeArray(beet.u8, 32)] - ], - 'CreateWalletInstructionArgs' -) -/** - * Accounts required by the _CreateWallet_ instruction - * - * @property [_writable_, **signer**] payer -* @property [_writable_] wallet -* @property [_writable_] vault -* @property [_writable_] authority - * @category Instructions - * @category CreateWallet - * @category generated - */ - export type CreateWalletInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - vault: web3.PublicKey - authority: web3.PublicKey - systemProgram?: web3.PublicKey - - } - - export const createWalletInstructionDiscriminator = 0; - - /** - * Creates a _CreateWallet_ instruction. - * - * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * - * @category Instructions - * @category CreateWallet - * @category generated - */ - export function createCreateWalletInstruction( - accounts: CreateWalletInstructionAccounts, -args: CreateWalletInstructionArgs , programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = CreateWalletStruct.serialize({ - instructionDiscriminator: createWalletInstructionDiscriminator, - ...args - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: true, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.vault, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.authority, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, - isWritable: false, - isSigner: false, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/Execute.ts b/sdk/solita-client/src/generated/instructions/Execute.ts deleted file mode 100644 index 16b951d..0000000 --- a/sdk/solita-client/src/generated/instructions/Execute.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - -/** - * @category Instructions - * @category Execute - * @category generated - */ -export type ExecuteInstructionArgs = { - instructions: Uint8Array -} -/** - * @category Instructions - * @category Execute - * @category generated - */ -export const ExecuteStruct = new beet.FixableBeetArgsStruct( - [ - ['instructionDiscriminator', beet.u8], - ['instructions', beet.bytes] - ], - 'ExecuteInstructionArgs' -) -/** - * Accounts required by the _Execute_ instruction - * - * @property [**signer**] payer -* @property [] wallet -* @property [] authority -* @property [] vault -* @property [] sysvarInstructions (optional) - * @category Instructions - * @category Execute - * @category generated - */ - export type ExecuteInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - authority: web3.PublicKey - vault: web3.PublicKey - sysvarInstructions?: web3.PublicKey - - } - - export const executeInstructionDiscriminator = 4; - - /** - * Creates a _Execute_ instruction. - * - * Optional accounts that are not provided default to the program ID since - * this was indicated in the IDL from which this instruction was generated. - * - * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * - * @category Instructions - * @category Execute - * @category generated - */ - export function createExecuteInstruction( - accounts: ExecuteInstructionAccounts, -args: ExecuteInstructionArgs , programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = ExecuteStruct.serialize({ - instructionDiscriminator: executeInstructionDiscriminator, - ...args - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.authority, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.vault, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.sysvarInstructions ?? programId, - isWritable: false, - isSigner: false, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/RemoveAuthority.ts b/sdk/solita-client/src/generated/instructions/RemoveAuthority.ts deleted file mode 100644 index 39c000b..0000000 --- a/sdk/solita-client/src/generated/instructions/RemoveAuthority.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - - -/** - * @category Instructions - * @category RemoveAuthority - * @category generated - */ -export const RemoveAuthorityStruct = new beet.BeetArgsStruct<{ instructionDiscriminator: number }>( - [ - ['instructionDiscriminator', beet.u8], - - ], - 'RemoveAuthorityInstructionArgs' -) -/** - * Accounts required by the _RemoveAuthority_ instruction - * - * @property [**signer**] payer -* @property [] wallet -* @property [**signer**] adminAuthority -* @property [_writable_] targetAuthority -* @property [_writable_] refundDestination -* @property [**signer**] authorizerSigner (optional) - * @category Instructions - * @category RemoveAuthority - * @category generated - */ - export type RemoveAuthorityInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - adminAuthority: web3.PublicKey - targetAuthority: web3.PublicKey - refundDestination: web3.PublicKey - authorizerSigner?: web3.PublicKey - - } - - export const removeAuthorityInstructionDiscriminator = 2; - - /** - * Creates a _RemoveAuthority_ instruction. - * - * Optional accounts that are not provided default to the program ID since - * this was indicated in the IDL from which this instruction was generated. - * - * @param accounts that will be accessed while the instruction is processed - * @category Instructions - * @category RemoveAuthority - * @category generated - */ - export function createRemoveAuthorityInstruction( - accounts: RemoveAuthorityInstructionAccounts, -programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = RemoveAuthorityStruct.serialize({ - instructionDiscriminator: removeAuthorityInstructionDiscriminator, - - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.adminAuthority, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.targetAuthority, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.refundDestination, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.authorizerSigner ?? programId, - isWritable: false, - isSigner: accounts.authorizerSigner != null, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/TransferOwnership.ts b/sdk/solita-client/src/generated/instructions/TransferOwnership.ts deleted file mode 100644 index a5943ef..0000000 --- a/sdk/solita-client/src/generated/instructions/TransferOwnership.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - -/** - * @category Instructions - * @category TransferOwnership - * @category generated - */ -export type TransferOwnershipInstructionArgs = { - newType: number, - newPubkey: number[] /* size: 33 */, - newHash: number[] /* size: 32 */ -} -/** - * @category Instructions - * @category TransferOwnership - * @category generated - */ -export const TransferOwnershipStruct = new beet.BeetArgsStruct( - [ - ['instructionDiscriminator', beet.u8], - ['newType', beet.u8], - ['newPubkey', beet.uniformFixedSizeArray(beet.u8, 33)], - ['newHash', beet.uniformFixedSizeArray(beet.u8, 32)] - ], - 'TransferOwnershipInstructionArgs' -) -/** - * Accounts required by the _TransferOwnership_ instruction - * - * @property [**signer**] payer -* @property [] wallet -* @property [_writable_] currentOwnerAuthority -* @property [_writable_] newOwnerAuthority -* @property [**signer**] authorizerSigner (optional) - * @category Instructions - * @category TransferOwnership - * @category generated - */ - export type TransferOwnershipInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - currentOwnerAuthority: web3.PublicKey - newOwnerAuthority: web3.PublicKey - systemProgram?: web3.PublicKey - authorizerSigner?: web3.PublicKey - - } - - export const transferOwnershipInstructionDiscriminator = 3; - - /** - * Creates a _TransferOwnership_ instruction. - * - * Optional accounts that are not provided default to the program ID since - * this was indicated in the IDL from which this instruction was generated. - * - * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * - * @category Instructions - * @category TransferOwnership - * @category generated - */ - export function createTransferOwnershipInstruction( - accounts: TransferOwnershipInstructionAccounts, -args: TransferOwnershipInstructionArgs , programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = TransferOwnershipStruct.serialize({ - instructionDiscriminator: transferOwnershipInstructionDiscriminator, - ...args - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.currentOwnerAuthority, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.newOwnerAuthority, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.authorizerSigner ?? programId, - isWritable: false, - isSigner: accounts.authorizerSigner != null, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/index.ts b/sdk/solita-client/src/generated/instructions/index.ts deleted file mode 100644 index b125669..0000000 --- a/sdk/solita-client/src/generated/instructions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './AddAuthority'; -export * from './CreateSession'; -export * from './CreateWallet'; -export * from './Execute'; -export * from './RemoveAuthority'; -export * from './TransferOwnership'; \ No newline at end of file diff --git a/sdk/solita-client/src/generated/types/AccountDiscriminator.ts b/sdk/solita-client/src/generated/types/AccountDiscriminator.ts deleted file mode 100644 index a769abe..0000000 --- a/sdk/solita-client/src/generated/types/AccountDiscriminator.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -/** - * @category enums - * @category generated - */ -export enum AccountDiscriminator { - Uninitialized, - Wallet, - Authority, - Session -} - -/** - * @category userTypes - * @category generated - */ -export const accountDiscriminatorBeet = beet.fixedScalarEnum(AccountDiscriminator) as beet.FixedSizeBeet \ No newline at end of file diff --git a/sdk/solita-client/src/generated/types/AuthorityType.ts b/sdk/solita-client/src/generated/types/AuthorityType.ts deleted file mode 100644 index 7a7e823..0000000 --- a/sdk/solita-client/src/generated/types/AuthorityType.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -/** - * @category enums - * @category generated - */ -export enum AuthorityType { - Ed25519, - Secp256r1 -} - -/** - * @category userTypes - * @category generated - */ -export const authorityTypeBeet = beet.fixedScalarEnum(AuthorityType) as beet.FixedSizeBeet \ No newline at end of file diff --git a/sdk/solita-client/src/generated/types/Role.ts b/sdk/solita-client/src/generated/types/Role.ts deleted file mode 100644 index 71a16f7..0000000 --- a/sdk/solita-client/src/generated/types/Role.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -/** - * @category enums - * @category generated - */ -export enum Role { - Owner, - Admin, - Spender -} - -/** - * @category userTypes - * @category generated - */ -export const roleBeet = beet.fixedScalarEnum(Role) as beet.FixedSizeBeet \ No newline at end of file diff --git a/sdk/solita-client/src/generated/types/index.ts b/sdk/solita-client/src/generated/types/index.ts deleted file mode 100644 index 2a7650c..0000000 --- a/sdk/solita-client/src/generated/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './AccountDiscriminator'; -export * from './AuthorityType'; -export * from './Role'; \ No newline at end of file diff --git a/sdk/solita-client/src/index.ts b/sdk/solita-client/src/index.ts deleted file mode 100644 index a569a11..0000000 --- a/sdk/solita-client/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './generated'; -export * from './utils'; diff --git a/sdk/solita-client/src/utils/client.ts b/sdk/solita-client/src/utils/client.ts deleted file mode 100644 index 696b403..0000000 --- a/sdk/solita-client/src/utils/client.ts +++ /dev/null @@ -1,694 +0,0 @@ -import { - Connection, - PublicKey, - SystemProgram, - SYSVAR_INSTRUCTIONS_PUBKEY, - TransactionInstruction, -} from '@solana/web3.js'; -import { PROGRAM_ID } from '../generated'; -import { AuthorityAccount } from '../generated/accounts'; -import { findWalletPda, findVaultPda, findAuthorityPda, findSessionPda, findDeferredExecPda } from './pdas'; -import { readAuthorityCounter } from './secp256r1'; -import { packCompactInstructions, computeAccountsHash, computeInstructionsHash, type CompactInstruction } from './packing'; -import { - createCreateWalletIx, - createAddAuthorityIx, - createRemoveAuthorityIx, - createTransferOwnershipIx, - createExecuteIx, - createCreateSessionIx, - createAuthorizeIx, - createExecuteDeferredIx, - createReclaimDeferredIx, - createRevokeSessionIx, - AUTH_TYPE_ED25519, - AUTH_TYPE_SECP256R1, - DISC_ADD_AUTHORITY, - DISC_REMOVE_AUTHORITY, - DISC_TRANSFER_OWNERSHIP, - DISC_EXECUTE, - DISC_CREATE_SESSION, - DISC_AUTHORIZE, - DISC_REVOKE_SESSION, -} from './instructions'; -import { signWithSecp256r1, buildDataPayloadForAdd, buildDataPayloadForTransfer, buildDataPayloadForSession, concatParts } from './signing'; -import { buildCompactLayout } from './compact'; -import type { CreateWalletOwner, AdminSigner, ExecuteSigner, Secp256r1SignerConfig, DeferredPayload } from './types'; - -// ─── Sysvar instruction indexes (auto-computed from account layouts) ── - -const SYSVAR_IX_INDEX_ADD_AUTHORITY = 6; -const SYSVAR_IX_INDEX_REMOVE_AUTHORITY = 5; -const SYSVAR_IX_INDEX_TRANSFER_OWNERSHIP = 6; -const SYSVAR_IX_INDEX_EXECUTE = 4; -const SYSVAR_IX_INDEX_CREATE_SESSION = 6; -const SYSVAR_IX_INDEX_AUTHORIZE = 6; -const SYSVAR_IX_INDEX_REVOKE_SESSION = 5; - -// ─── Internal helpers ───────────────────────────────────────────────── - -/** Resolves a CreateWalletOwner to the low-level fields needed by IX builders */ -function resolveOwnerFields(owner: CreateWalletOwner): { - authType: number; - credentialOrPubkey: Uint8Array; - secp256r1Pubkey?: Uint8Array; - rpId?: string; -} { - if (owner.type === 'ed25519') { - return { authType: AUTH_TYPE_ED25519, credentialOrPubkey: owner.publicKey.toBytes() }; - } - return { - authType: AUTH_TYPE_SECP256R1, - credentialOrPubkey: owner.credentialIdHash, - secp256r1Pubkey: owner.compressedPubkey, - rpId: owner.rpId, - }; -} - -/** Gets the credential bytes (PDA seed) from a CreateWalletOwner */ -function ownerCredentialBytes(owner: CreateWalletOwner): Uint8Array { - return owner.type === 'ed25519' ? owner.publicKey.toBytes() : owner.credentialIdHash; -} - -export class LazorKitClient { - constructor( - public readonly connection: Connection, - public readonly programId: PublicKey = PROGRAM_ID, - ) {} - - // ─── PDA helpers ───────────────────────────────────────────────── - - findWallet(userSeed: Uint8Array) { return findWalletPda(userSeed, this.programId); } - findVault(walletPda: PublicKey) { return findVaultPda(walletPda, this.programId); } - findAuthority(walletPda: PublicKey, credIdHash: Uint8Array) { - return findAuthorityPda(walletPda, credIdHash, this.programId); - } - findSession(walletPda: PublicKey, sessionKey: Uint8Array) { - return findSessionPda(walletPda, sessionKey, this.programId); - } - findDeferredExec(walletPda: PublicKey, authorityPda: PublicKey, counter: number) { - return findDeferredExecPda(walletPda, authorityPda, counter, this.programId); - } - - // ─── Account readers ───────────────────────────────────────────── - - async fetchAuthority(authorityPda: PublicKey): Promise { - return AuthorityAccount.fromAccountAddress(this.connection, authorityPda); - } - - async readCounter(authorityPda: PublicKey): Promise { - return readAuthorityCounter(this.connection, authorityPda); - } - - // ─── CreateWallet ──────────────────────────────────────────────── - - /** - * Create a new LazorKit wallet with the given owner. - * - * @example Ed25519 owner - * ```typescript - * const { instructions, walletPda, vaultPda } = client.createWallet({ - * payer: payer.publicKey, - * userSeed: randomBytes(32), - * owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, - * }); - * ``` - * - * @example Secp256r1 (passkey) owner - * ```typescript - * const { instructions, walletPda, vaultPda } = client.createWallet({ - * payer: payer.publicKey, - * userSeed: randomBytes(32), - * owner: { - * type: 'secp256r1', - * credentialIdHash, - * compressedPubkey, - * rpId: 'example.com', - * }, - * }); - * ``` - */ - createWallet(params: { - payer: PublicKey; - userSeed: Uint8Array; - owner: CreateWalletOwner; - }): { instructions: TransactionInstruction[]; walletPda: PublicKey; vaultPda: PublicKey; authorityPda: PublicKey } { - const [walletPda] = this.findWallet(params.userSeed); - const [vaultPda] = this.findVault(walletPda); - const { authType, credentialOrPubkey, secp256r1Pubkey, rpId } = resolveOwnerFields(params.owner); - const [authorityPda, authBump] = this.findAuthority(walletPda, credentialOrPubkey); - - const ix = createCreateWalletIx({ - payer: params.payer, walletPda, vaultPda, authorityPda, - userSeed: params.userSeed, authType, authBump, - credentialOrPubkey, secp256r1Pubkey, rpId, - programId: this.programId, - }); - return { instructions: [ix], walletPda, vaultPda, authorityPda }; - } - - // ─── AddAuthority (unified) ───────────────────────────────────── - - /** - * Add a new authority to the wallet. - * - * @example Add Ed25519 admin via Ed25519 owner - * ```typescript - * const { instructions, newAuthorityPda } = await client.addAuthority({ - * payer: payer.publicKey, - * walletPda, - * adminSigner: ed25519(ownerKp.publicKey), - * newAuthority: { type: 'ed25519', publicKey: adminKp.publicKey }, - * role: ROLE_ADMIN, - * }); - * ``` - * - * @example Add Secp256r1 spender via Secp256r1 owner - * ```typescript - * const { instructions, newAuthorityPda } = await client.addAuthority({ - * payer: payer.publicKey, - * walletPda, - * adminSigner: secp256r1(ceoSigner), - * newAuthority: { type: 'secp256r1', credentialIdHash, compressedPubkey, rpId }, - * role: ROLE_SPENDER, - * }); - * ``` - */ - async addAuthority(params: { - payer: PublicKey; - walletPda: PublicKey; - adminSigner: AdminSigner; - newAuthority: CreateWalletOwner; - role: number; - }): Promise<{ instructions: TransactionInstruction[]; newAuthorityPda: PublicKey }> { - const { authType: newType, credentialOrPubkey, secp256r1Pubkey, rpId } = resolveOwnerFields(params.newAuthority); - const [newAuthorityPda] = this.findAuthority(params.walletPda, credentialOrPubkey); - const s = params.adminSigner; - - if (s.type === 'ed25519') { - const ix = createAddAuthorityIx({ - payer: params.payer, walletPda: params.walletPda, - adminAuthorityPda: s.authorityPda ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0], - newAuthorityPda, newType, newRole: params.role, - credentialOrPubkey, secp256r1Pubkey, rpId, - authorizerSigner: s.publicKey, programId: this.programId, - }); - return { instructions: [ix], newAuthorityPda }; - } - - // Secp256r1 - const adminAuthorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(adminAuthorityPda)) + 1; - - const dataPayload = buildDataPayloadForAdd( - newType, params.role, credentialOrPubkey, secp256r1Pubkey, rpId, - ); - const signedPayload = concatParts([dataPayload, params.payer.toBytes()]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_ADD_AUTHORITY]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_ADD_AUTHORITY, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createAddAuthorityIx({ - payer: params.payer, walletPda: params.walletPda, adminAuthorityPda, newAuthorityPda, - newType, newRole: params.role, credentialOrPubkey, secp256r1Pubkey, rpId, - authPayload, programId: this.programId, - }); - return { instructions: [precompileIx, ix], newAuthorityPda }; - } - - // ─── RemoveAuthority (unified) ────────────────────────────────── - - async removeAuthority(params: { - payer: PublicKey; - walletPda: PublicKey; - adminSigner: AdminSigner; - targetAuthorityPda: PublicKey; - refundDestination?: PublicKey; - }): Promise<{ instructions: TransactionInstruction[] }> { - const refundDest = params.refundDestination ?? params.payer; - const s = params.adminSigner; - - if (s.type === 'ed25519') { - const ix = createRemoveAuthorityIx({ - payer: params.payer, walletPda: params.walletPda, - adminAuthorityPda: s.authorityPda ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0], - targetAuthorityPda: params.targetAuthorityPda, refundDestination: refundDest, - authorizerSigner: s.publicKey, programId: this.programId, - }); - return { instructions: [ix] }; - } - - const adminAuthorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(adminAuthorityPda)) + 1; - - const signedPayload = concatParts([ - params.targetAuthorityPda.toBytes(), refundDest.toBytes(), - ]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_REMOVE_AUTHORITY]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_REMOVE_AUTHORITY, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createRemoveAuthorityIx({ - payer: params.payer, walletPda: params.walletPda, adminAuthorityPda, - targetAuthorityPda: params.targetAuthorityPda, refundDestination: refundDest, - authPayload, programId: this.programId, - }); - return { instructions: [precompileIx, ix] }; - } - - // ─── TransferOwnership (unified) ──────────────────────────────── - - /** - * Transfer wallet ownership to a new authority. - * - * @example Transfer to new Secp256r1 owner - * ```typescript - * const { instructions } = await client.transferOwnership({ - * payer: payer.publicKey, - * walletPda, - * ownerSigner: secp256r1(ceoSigner), - * newOwner: { type: 'secp256r1', credentialIdHash, compressedPubkey, rpId }, - * }); - * ``` - */ - async transferOwnership(params: { - payer: PublicKey; - walletPda: PublicKey; - ownerSigner: AdminSigner; - newOwner: CreateWalletOwner; - }): Promise<{ instructions: TransactionInstruction[]; newOwnerAuthorityPda: PublicKey }> { - const { authType: newType, credentialOrPubkey, secp256r1Pubkey, rpId } = resolveOwnerFields(params.newOwner); - const [newOwnerAuthorityPda] = this.findAuthority(params.walletPda, credentialOrPubkey); - const s = params.ownerSigner; - - if (s.type === 'ed25519') { - const ix = createTransferOwnershipIx({ - payer: params.payer, walletPda: params.walletPda, - currentOwnerAuthorityPda: s.authorityPda ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0], - newOwnerAuthorityPda, newType, credentialOrPubkey, secp256r1Pubkey, rpId, - authorizerSigner: s.publicKey, programId: this.programId, - }); - return { instructions: [ix], newOwnerAuthorityPda }; - } - - const currentOwnerAuthorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(currentOwnerAuthorityPda)) + 1; - - const dataPayload = buildDataPayloadForTransfer(newType, credentialOrPubkey, secp256r1Pubkey, rpId); - const signedPayload = concatParts([dataPayload, params.payer.toBytes()]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_TRANSFER_OWNERSHIP]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_TRANSFER_OWNERSHIP, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createTransferOwnershipIx({ - payer: params.payer, walletPda: params.walletPda, - currentOwnerAuthorityPda, newOwnerAuthorityPda, newType, - credentialOrPubkey, secp256r1Pubkey, rpId, - authPayload, programId: this.programId, - }); - return { instructions: [precompileIx, ix], newOwnerAuthorityPda }; - } - - // ─── CreateSession (unified) ──────────────────────────────────── - - /** - * Create a session key for the wallet. - * - * @example - * ```typescript - * const { instructions, sessionPda } = await client.createSession({ - * payer: payer.publicKey, - * walletPda, - * adminSigner: ed25519(ownerKp.publicKey), - * sessionKey: sessionKp.publicKey, - * expiresAt: currentSlot + 9000n, - * }); - * ``` - */ - async createSession(params: { - payer: PublicKey; - walletPda: PublicKey; - adminSigner: AdminSigner; - sessionKey: PublicKey; - expiresAt: bigint; - }): Promise<{ instructions: TransactionInstruction[]; sessionPda: PublicKey }> { - const sessionKeyBytes = params.sessionKey.toBytes(); - const [sessionPda] = this.findSession(params.walletPda, sessionKeyBytes); - const s = params.adminSigner; - - if (s.type === 'ed25519') { - const ix = createCreateSessionIx({ - payer: params.payer, walletPda: params.walletPda, - adminAuthorityPda: s.authorityPda ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0], - sessionPda, sessionKey: sessionKeyBytes, expiresAt: params.expiresAt, - authorizerSigner: s.publicKey, programId: this.programId, - }); - return { instructions: [ix], sessionPda }; - } - - const adminAuthorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(adminAuthorityPda)) + 1; - - const dataPayload = buildDataPayloadForSession(sessionKeyBytes, params.expiresAt); - const signedPayload = concatParts([dataPayload, params.payer.toBytes()]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_CREATE_SESSION]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_CREATE_SESSION, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createCreateSessionIx({ - payer: params.payer, walletPda: params.walletPda, adminAuthorityPda, sessionPda, - sessionKey: sessionKeyBytes, expiresAt: params.expiresAt, - authPayload, programId: this.programId, - }); - return { instructions: [precompileIx, ix], sessionPda }; - } - - // ─── Execute (unified, accepts standard TransactionInstructions) ─ - - /** - * Execute arbitrary Solana instructions via the wallet. - * - * Works with any signer type: Ed25519, Secp256r1 (passkey), or Session key. - * Pass standard `TransactionInstruction[]` — the SDK handles compact encoding, - * account indexing, and signing automatically. - * - * @example - * ```typescript - * const [vault] = client.findVault(walletPda); - * const { instructions } = await client.execute({ - * payer: payer.publicKey, - * walletPda, - * signer: secp256r1(mySigner), - * instructions: [ - * SystemProgram.transfer({ fromPubkey: vault, toPubkey: recipient, lamports: 1_000_000 }), - * ], - * }); - * await sendAndConfirmTransaction(connection, new Transaction().add(...instructions), [payer]); - * ``` - */ - async execute(params: { - payer: PublicKey; - walletPda: PublicKey; - signer: ExecuteSigner; - instructions: TransactionInstruction[]; - }): Promise<{ instructions: TransactionInstruction[] }> { - const [vaultPda] = this.findVault(params.walletPda); - const s = params.signer; - - switch (s.type) { - case 'ed25519': { - const authorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0]; - // Ed25519: signer at index 4 (program expects it there) - const fixedAccounts = [params.payer, params.walletPda, authorityPda, vaultPda, s.publicKey]; - const { compactInstructions, remainingAccounts } = buildCompactLayout(fixedAccounts, params.instructions); - const packed = packCompactInstructions(compactInstructions); - const ix = createExecuteIx({ - payer: params.payer, walletPda: params.walletPda, - authorityPda, vaultPda, packedInstructions: packed, - authorizerSigner: s.publicKey, - remainingAccounts, programId: this.programId, - }); - return { instructions: [ix] }; - } - - case 'secp256r1': { - const authorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(authorityPda)) + 1; - - // Secp256r1: sysvar_instructions at index 4 - const fixedAccounts = [params.payer, params.walletPda, authorityPda, vaultPda, SYSVAR_INSTRUCTIONS_PUBKEY]; - const { compactInstructions, remainingAccounts } = buildCompactLayout(fixedAccounts, params.instructions); - const packed = packCompactInstructions(compactInstructions); - - // Compute accounts hash for signature binding - const allAccountMetas = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: authorityPda, isSigner: false, isWritable: true }, - { pubkey: vaultPda, isSigner: false, isWritable: true }, - { pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }, - ...remainingAccounts, - ]; - const accountsHash = computeAccountsHash(allAccountMetas, compactInstructions); - const signedPayload = concatParts([packed, accountsHash]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_EXECUTE]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_EXECUTE, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createExecuteIx({ - payer: params.payer, walletPda: params.walletPda, - authorityPda, vaultPda, packedInstructions: packed, - authPayload, remainingAccounts, programId: this.programId, - }); - return { instructions: [precompileIx, ix] }; - } - - case 'session': { - // Session: sessionKey as signer is included in fixed accounts for index mapping - const fixedAccounts = [params.payer, params.walletPda, s.sessionPda, vaultPda, s.sessionKeyPubkey]; - const { compactInstructions, remainingAccounts } = buildCompactLayout(fixedAccounts, params.instructions); - const packed = packCompactInstructions(compactInstructions); - - // Session key must be prepended to remaining accounts as a signer - const sessionKeyMeta = { pubkey: s.sessionKeyPubkey, isSigner: true, isWritable: false }; - const allRemaining = [sessionKeyMeta, ...remainingAccounts]; - - const ix = createExecuteIx({ - payer: params.payer, walletPda: params.walletPda, - authorityPda: s.sessionPda, vaultPda, packedInstructions: packed, - remainingAccounts: allRemaining, programId: this.programId, - }); - return { instructions: [ix] }; - } - } - } - - // ─── TransferSol (convenience) ────────────────────────────────── - - /** - * Transfer SOL from the wallet vault to a recipient. - * Works with any signer type. - * - * @example - * ```typescript - * const { instructions } = await client.transferSol({ - * payer: payer.publicKey, - * walletPda, - * signer: secp256r1(mySigner), - * recipient: destination, - * lamports: 1_000_000n, - * }); - * ``` - */ - async transferSol(params: { - payer: PublicKey; - walletPda: PublicKey; - signer: ExecuteSigner; - recipient: PublicKey; - lamports: bigint | number; - }): Promise<{ instructions: TransactionInstruction[] }> { - const [vaultPda] = this.findVault(params.walletPda); - const amount = typeof params.lamports === 'bigint' ? Number(params.lamports) : params.lamports; - - return this.execute({ - payer: params.payer, walletPda: params.walletPda, signer: params.signer, - instructions: [SystemProgram.transfer({ fromPubkey: vaultPda, toPubkey: params.recipient, lamports: amount })], - }); - } - - // ─── Authorize (deferred execution TX1) ───────────────────────── - - /** - * Authorize deferred execution. Pass standard TransactionInstructions - * — the SDK handles compact encoding and hash computation. - * - * Returns pre-computed `deferredPayload` for TX2. - */ - async authorize(params: { - payer: PublicKey; - walletPda: PublicKey; - signer: Secp256r1SignerConfig; - /** Standard instructions to defer */ - instructions: TransactionInstruction[]; - /** Expiry offset in slots (default 300 = ~2 minutes) */ - expiryOffset?: number; - }): Promise<{ - instructions: TransactionInstruction[]; - deferredExecPda: PublicKey; - counter: number; - deferredPayload: DeferredPayload; - }> { - const [vaultPda] = this.findVault(params.walletPda); - const s = params.signer; - const authorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(authorityPda)) + 1; - const expiryOffset = params.expiryOffset ?? 300; - - const [deferredExecPda] = this.findDeferredExec(params.walletPda, authorityPda, counter); - - // TX2 fixed accounts: payer, wallet, vault, deferred, refund_destination (=payer) - const tx2FixedAccounts = [params.payer, params.walletPda, vaultPda, deferredExecPda, params.payer]; - const { compactInstructions, remainingAccounts } = buildCompactLayout(tx2FixedAccounts, params.instructions); - - // Compute hashes - const instructionsHash = computeInstructionsHash(compactInstructions); - const tx2AccountMetas = [ - { pubkey: params.payer, isSigner: true, isWritable: true }, - { pubkey: params.walletPda, isSigner: false, isWritable: true }, - { pubkey: vaultPda, isSigner: false, isWritable: true }, - { pubkey: deferredExecPda, isSigner: false, isWritable: true }, - { pubkey: params.payer, isSigner: false, isWritable: true }, // refund dest - ...remainingAccounts, - ]; - const accountsHash = computeAccountsHash(tx2AccountMetas, compactInstructions); - const expiryOffsetBuf = new Uint8Array(2); - expiryOffsetBuf[0] = expiryOffset & 0xff; - expiryOffsetBuf[1] = (expiryOffset >> 8) & 0xff; - const signedPayload = concatParts([instructionsHash, accountsHash, expiryOffsetBuf]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_AUTHORIZE]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_AUTHORIZE, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const authorizeIx = createAuthorizeIx({ - payer: params.payer, walletPda: params.walletPda, authorityPda, deferredExecPda, - instructionsHash, accountsHash, expiryOffset, authPayload, - programId: this.programId, - }); - - return { - instructions: [precompileIx, authorizeIx], - deferredExecPda, - counter, - deferredPayload: { walletPda: params.walletPda, deferredExecPda, compactInstructions, remainingAccounts }, - }; - } - - // ─── ExecuteDeferred (from payload) ───────────────────────────── - - /** - * Build TX2 from the payload returned by `authorize()`. - */ - executeDeferredFromPayload(params: { - payer: PublicKey; - deferredPayload: DeferredPayload; - refundDestination?: PublicKey; - }): { instructions: TransactionInstruction[] } { - const [vaultPda] = this.findVault(params.deferredPayload.walletPda); - const refundDest = params.refundDestination ?? params.payer; - const packed = packCompactInstructions(params.deferredPayload.compactInstructions); - const ix = createExecuteDeferredIx({ - payer: params.payer, walletPda: params.deferredPayload.walletPda, vaultPda, - deferredExecPda: params.deferredPayload.deferredExecPda, - refundDestination: refundDest, packedInstructions: packed, - remainingAccounts: params.deferredPayload.remainingAccounts, - programId: this.programId, - }); - return { instructions: [ix] }; - } - - // ─── ReclaimDeferred ──────────────────────────────────────────── - - reclaimDeferred(params: { - payer: PublicKey; - deferredExecPda: PublicKey; - refundDestination?: PublicKey; - }): { instructions: TransactionInstruction[] } { - const ix = createReclaimDeferredIx({ - payer: params.payer, - deferredExecPda: params.deferredExecPda, - refundDestination: params.refundDestination ?? params.payer, - programId: this.programId, - }); - return { instructions: [ix] }; - } - - // ─── RevokeSession ───────────────────────────────────────────── - - /** - * Revoke a session key early (before expiry). - * Only Owner or Admin can revoke. Refunds session rent. - * - * @example Revoke with Ed25519 admin - * ```typescript - * const { instructions } = await client.revokeSession({ - * payer: payer.publicKey, - * walletPda, - * adminSigner: ed25519(adminKp.publicKey, adminAuthorityPda), - * sessionPda, - * }); - * ``` - */ - async revokeSession(params: { - payer: PublicKey; - walletPda: PublicKey; - adminSigner: AdminSigner; - sessionPda: PublicKey; - refundDestination?: PublicKey; - }): Promise<{ instructions: TransactionInstruction[] }> { - const refundDest = params.refundDestination ?? params.payer; - const s = params.adminSigner; - - if (s.type === 'ed25519') { - const ix = createRevokeSessionIx({ - payer: params.payer, walletPda: params.walletPda, - adminAuthorityPda: s.authorityPda ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0], - sessionPda: params.sessionPda, refundDestination: refundDest, - authorizerSigner: s.publicKey, programId: this.programId, - }); - return { instructions: [ix] }; - } - - const adminAuthorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(adminAuthorityPda)) + 1; - - const signedPayload = concatParts([ - params.sessionPda.toBytes(), refundDest.toBytes(), - ]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_REVOKE_SESSION]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_REVOKE_SESSION, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createRevokeSessionIx({ - payer: params.payer, walletPda: params.walletPda, adminAuthorityPda, - sessionPda: params.sessionPda, refundDestination: refundDest, - authPayload, programId: this.programId, - }); - return { instructions: [precompileIx, ix] }; - } -} diff --git a/sdk/solita-client/src/utils/compact.ts b/sdk/solita-client/src/utils/compact.ts deleted file mode 100644 index ffe078d..0000000 --- a/sdk/solita-client/src/utils/compact.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { PublicKey, TransactionInstruction } from '@solana/web3.js'; -import type { CompactInstruction } from './packing'; - -/** - * Converts standard Solana TransactionInstructions into the compact format - * expected by the Execute instruction. Automatically computes account indexes - * and builds the remaining accounts list. - * - * @param fixedAccounts - Accounts already in the instruction layout - * (e.g., payer, wallet, authority, vault, sysvar) - * @param instructions - Standard Solana TransactionInstructions to convert - */ -export function buildCompactLayout( - fixedAccounts: PublicKey[], - instructions: TransactionInstruction[], -): { - compactInstructions: CompactInstruction[]; - remainingAccounts: { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }[]; -} { - // Index map: pubkey base58 -> index in the full account layout - const indexMap = new Map(); - for (let i = 0; i < fixedAccounts.length; i++) { - indexMap.set(fixedAccounts[i].toBase58(), i); - } - - // Collect remaining accounts (unique, preserving insertion order) - const remainingMap = new Map(); - - for (const ix of instructions) { - // Program ID (never signer, never writable) - const progKey = ix.programId.toBase58(); - if (!indexMap.has(progKey) && !remainingMap.has(progKey)) { - remainingMap.set(progKey, { pubkey: ix.programId, isSigner: false, isWritable: false }); - } - // Account keys - for (const key of ix.keys) { - const k = key.pubkey.toBase58(); - if (!indexMap.has(k)) { - if (remainingMap.has(k)) { - // Merge: most permissive flags win - const existing = remainingMap.get(k)!; - existing.isSigner = existing.isSigner || key.isSigner; - existing.isWritable = existing.isWritable || key.isWritable; - } else { - remainingMap.set(k, { pubkey: key.pubkey, isSigner: key.isSigner, isWritable: key.isWritable }); - } - } - } - } - - // Assign indexes to remaining accounts (after fixed accounts) - const remainingAccounts = Array.from(remainingMap.values()); - let nextIndex = fixedAccounts.length; - for (const acc of remainingAccounts) { - indexMap.set(acc.pubkey.toBase58(), nextIndex++); - } - - // Convert each instruction to compact format - const compactInstructions: CompactInstruction[] = instructions.map(ix => ({ - programIdIndex: indexMap.get(ix.programId.toBase58())!, - accountIndexes: ix.keys.map(k => indexMap.get(k.pubkey.toBase58())!), - data: new Uint8Array(ix.data), - })); - - return { compactInstructions, remainingAccounts }; -} diff --git a/sdk/solita-client/src/utils/ed25519.ts b/sdk/solita-client/src/utils/ed25519.ts deleted file mode 100644 index 1855e55..0000000 --- a/sdk/solita-client/src/utils/ed25519.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { PublicKey } from '@solana/web3.js'; - -/** - * Callback interface for Ed25519 signing. The SDK never touches private keys. - * Implementors provide their own signing logic (e.g. Keypair.sign, hardware wallet). - */ -export interface Ed25519Signer { - publicKey: PublicKey; - sign(message: Uint8Array): Promise; -} diff --git a/sdk/solita-client/src/utils/errors.ts b/sdk/solita-client/src/utils/errors.ts deleted file mode 100644 index 87de488..0000000 --- a/sdk/solita-client/src/utils/errors.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Friendly error code map for LazorKit program errors. - * Re-exports Solita-generated error classes and provides a lookup utility. - */ -export { - errorFromCode, - errorFromName, - InvalidAuthorityPayloadError, - PermissionDeniedError, - InvalidInstructionError, - InvalidPubkeyError, - InvalidMessageHashError, - SignatureReusedError, - InvalidSignatureAgeError, - InvalidSessionDurationError, - SessionExpiredError, - AuthorityDoesNotSupportSessionError, - InvalidAuthenticationKindError, - InvalidMessageError, - SelfReentrancyNotAllowedError, -} from '../generated/errors'; - -/** Map of error code → human-readable name */ -export const ERROR_NAMES: Record = { - 3001: 'InvalidAuthorityPayload', - 3002: 'PermissionDenied', - 3003: 'InvalidInstruction', - 3004: 'InvalidPubkey', - 3005: 'InvalidMessageHash', - 3006: 'SignatureReused', - 3007: 'InvalidSignatureAge', - 3008: 'InvalidSessionDuration', - 3009: 'SessionExpired', - 3010: 'AuthorityDoesNotSupportSession', - 3011: 'InvalidAuthenticationKind', - 3012: 'InvalidMessage', - 3013: 'SelfReentrancyNotAllowed', - 3014: 'DeferredAuthorizationExpired', - 3015: 'DeferredHashMismatch', - 3016: 'InvalidExpiryWindow', - 3017: 'UnauthorizedReclaim', - 3018: 'DeferredAuthorizationNotExpired', -}; - -/** - * Extracts the custom program error code from a Solana SendTransactionError. - * Returns null if the error is not a custom program error. - */ -export function extractErrorCode(err: unknown): number | null { - const msg = String(err); - const match = msg.match(/custom program error: 0x([0-9a-fA-F]+)/); - if (match) return parseInt(match[1], 16); - const match2 = msg.match(/Custom\((\d+)\)/); - if (match2) return parseInt(match2[1], 10); - return null; -} diff --git a/sdk/solita-client/src/utils/index.ts b/sdk/solita-client/src/utils/index.ts deleted file mode 100644 index f5315de..0000000 --- a/sdk/solita-client/src/utils/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export * from './pdas'; -export * from './secp256r1'; -export * from './ed25519'; -export * from './packing'; -export * from './errors'; -export * from './instructions'; -export * from './types'; -export * from './signing'; -export * from './compact'; -export * from './client'; diff --git a/sdk/solita-client/src/utils/instructions.ts b/sdk/solita-client/src/utils/instructions.ts deleted file mode 100644 index 5094892..0000000 --- a/sdk/solita-client/src/utils/instructions.ts +++ /dev/null @@ -1,531 +0,0 @@ -/** - * Hand-written instruction builders that produce the exact raw binary format - * the LazorKit program expects. Solita-generated builders use beet which adds - * length prefixes to `bytes` fields, causing a mismatch. - */ -import { - PublicKey, - TransactionInstruction, - SystemProgram, - SYSVAR_INSTRUCTIONS_PUBKEY, - SYSVAR_RENT_PUBKEY, -} from '@solana/web3.js'; -import { PROGRAM_ID } from '../generated'; - -// ─── Discriminators ────────────────────────────────────────────────── -export const DISC_CREATE_WALLET = 0; -export const DISC_ADD_AUTHORITY = 1; -export const DISC_REMOVE_AUTHORITY = 2; -export const DISC_TRANSFER_OWNERSHIP = 3; -export const DISC_EXECUTE = 4; -export const DISC_CREATE_SESSION = 5; -export const DISC_AUTHORIZE = 6; -export const DISC_EXECUTE_DEFERRED = 7; -export const DISC_RECLAIM_DEFERRED = 8; -export const DISC_REVOKE_SESSION = 9; - -// ─── Authority types ───────────────────────────────────────────────── -export const AUTH_TYPE_ED25519 = 0; -export const AUTH_TYPE_SECP256R1 = 1; - -// ─── Roles ─────────────────────────────────────────────────────────── -export const ROLE_OWNER = 0; -export const ROLE_ADMIN = 1; -export const ROLE_SPENDER = 2; - -// ─── CreateWallet ──────────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * [user_seed(32)][auth_type(1)][auth_bump(1)][padding(6)] - * Ed25519: [pubkey(32)] - * Secp256r1: [credential_id_hash(32)][pubkey(33)][rpIdLen(1)][rpId(N)] - */ -export function createCreateWalletIx(params: { - payer: PublicKey; - walletPda: PublicKey; - vaultPda: PublicKey; - authorityPda: PublicKey; - userSeed: Uint8Array; - authType: number; - authBump: number; - /** Ed25519: 32-byte pubkey. Secp256r1: 32-byte credential_id_hash */ - credentialOrPubkey: Uint8Array; - /** Secp256r1 only: 33-byte compressed pubkey */ - secp256r1Pubkey?: Uint8Array; - /** Secp256r1 only: RP ID string (stored on-chain for per-tx savings) */ - rpId?: string; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [ - new Uint8Array([DISC_CREATE_WALLET]), - params.userSeed, - new Uint8Array([params.authType, params.authBump]), - new Uint8Array(6), // padding - params.credentialOrPubkey, - ]; - if (params.authType === AUTH_TYPE_SECP256R1 && params.secp256r1Pubkey) { - parts.push(params.secp256r1Pubkey); - if (params.rpId) { - const rpIdBytes = Buffer.from(params.rpId, 'utf-8'); - parts.push(new Uint8Array([rpIdBytes.length])); - parts.push(new Uint8Array(rpIdBytes)); - } - } - - return new TransactionInstruction({ - programId: pid, - keys: [ - { pubkey: params.payer, isSigner: true, isWritable: true }, - { pubkey: params.walletPda, isSigner: false, isWritable: true }, - { pubkey: params.vaultPda, isSigner: false, isWritable: true }, - { pubkey: params.authorityPda, isSigner: false, isWritable: true }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, - ], - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── AddAuthority ──────────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * [auth_type(1)][new_role(1)][padding(6)] - * Ed25519: [pubkey(32)] - * Secp256r1: [credential_id_hash(32)][pubkey(33)][rpIdLen(1)][rpId(N)] + [auth_payload(...)] - */ -export function createAddAuthorityIx(params: { - payer: PublicKey; - walletPda: PublicKey; - adminAuthorityPda: PublicKey; - newAuthorityPda: PublicKey; - newType: number; - newRole: number; - /** Ed25519: 32-byte pubkey. Secp256r1: 32-byte credential_id_hash */ - credentialOrPubkey: Uint8Array; - /** Secp256r1 only: 33-byte compressed pubkey */ - secp256r1Pubkey?: Uint8Array; - /** Secp256r1 only: RP ID string for the new authority */ - rpId?: string; - /** Auth payload for Secp256r1 admin authentication */ - authPayload?: Uint8Array; - /** For Ed25519 admin: the signer pubkey */ - authorizerSigner?: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [ - new Uint8Array([DISC_ADD_AUTHORITY]), - new Uint8Array([params.newType, params.newRole]), - new Uint8Array(6), // padding - params.credentialOrPubkey, - ]; - if (params.newType === AUTH_TYPE_SECP256R1 && params.secp256r1Pubkey) { - parts.push(params.secp256r1Pubkey); - if (params.rpId) { - const rpIdBytes = Buffer.from(params.rpId, 'utf-8'); - parts.push(new Uint8Array([rpIdBytes.length])); - parts.push(new Uint8Array(rpIdBytes)); - } - } - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.adminAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.newAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, - ]; - - // Secp256r1 auth needs sysvar instructions; Ed25519 needs the signer - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── RemoveAuthority ───────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * Secp256r1: [auth_payload(...)] - * Ed25519: empty (auth is via signer) - */ -export function createRemoveAuthorityIx(params: { - payer: PublicKey; - walletPda: PublicKey; - adminAuthorityPda: PublicKey; - targetAuthorityPda: PublicKey; - refundDestination: PublicKey; - authPayload?: Uint8Array; - authorizerSigner?: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [new Uint8Array([DISC_REMOVE_AUTHORITY])]; - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.adminAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.targetAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.refundDestination, isSigner: false, isWritable: true }, - ]; - - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── TransferOwnership ────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * [auth_type(1)] - * Ed25519: [pubkey(32)] - * Secp256r1: [credential_id_hash(32)][pubkey(33)][rpIdLen(1)][rpId(N)] + [auth_payload(...)] - */ -export function createTransferOwnershipIx(params: { - payer: PublicKey; - walletPda: PublicKey; - currentOwnerAuthorityPda: PublicKey; - newOwnerAuthorityPda: PublicKey; - newType: number; - /** Ed25519: 32-byte pubkey. Secp256r1: 32-byte credential_id_hash */ - credentialOrPubkey: Uint8Array; - /** Secp256r1 only: 33-byte compressed pubkey */ - secp256r1Pubkey?: Uint8Array; - /** Secp256r1 only: RP ID string for the new owner */ - rpId?: string; - authPayload?: Uint8Array; - authorizerSigner?: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [ - new Uint8Array([DISC_TRANSFER_OWNERSHIP]), - new Uint8Array([params.newType]), - params.credentialOrPubkey, - ]; - if (params.newType === AUTH_TYPE_SECP256R1 && params.secp256r1Pubkey) { - parts.push(params.secp256r1Pubkey); - if (params.rpId) { - const rpIdBytes = Buffer.from(params.rpId, 'utf-8'); - parts.push(new Uint8Array([rpIdBytes.length])); - parts.push(new Uint8Array(rpIdBytes)); - } - } - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.currentOwnerAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.newOwnerAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, - ]; - - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── Execute ───────────────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * [compact_instructions(variable)] - * Secp256r1: [auth_payload(variable)] - */ -export function createExecuteIx(params: { - payer: PublicKey; - walletPda: PublicKey; - authorityPda: PublicKey; - vaultPda: PublicKey; - packedInstructions: Uint8Array; - authPayload?: Uint8Array; - /** For Ed25519 auth: the signer pubkey (placed at account index 4) */ - authorizerSigner?: PublicKey; - /** Additional account metas for the inner CPI instructions */ - remainingAccounts?: { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }[]; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [ - new Uint8Array([DISC_EXECUTE]), - params.packedInstructions, - ]; - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.authorityPda, isSigner: false, isWritable: true }, - { pubkey: params.vaultPda, isSigner: false, isWritable: true }, - ]; - - // Ed25519 needs the signer at index 4; Secp256r1 needs sysvar instructions - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - // Remaining accounts for CPI targets - if (params.remainingAccounts) { - keys.push(...params.remainingAccounts); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── CreateSession ─────────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * [session_key(32)][expires_at(8)] - * Secp256r1: [auth_payload(variable)] - */ -export function createCreateSessionIx(params: { - payer: PublicKey; - walletPda: PublicKey; - adminAuthorityPda: PublicKey; - sessionPda: PublicKey; - sessionKey: Uint8Array; - expiresAt: bigint; - authPayload?: Uint8Array; - authorizerSigner?: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const expiresAtBuf = Buffer.alloc(8); - expiresAtBuf.writeBigInt64LE(params.expiresAt); - - const parts: Uint8Array[] = [ - new Uint8Array([DISC_CREATE_SESSION]), - params.sessionKey, - new Uint8Array(expiresAtBuf), - ]; - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.adminAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.sessionPda, isSigner: false, isWritable: true }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, - ]; - - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── Authorize (Deferred Execution tx1) ───────────────────────────── -/** - * Instruction data layout (after discriminator): - * [instructions_hash(32)][accounts_hash(32)][expiry_offset(2)][auth_payload(variable)] - */ -export function createAuthorizeIx(params: { - payer: PublicKey; - walletPda: PublicKey; - authorityPda: PublicKey; - deferredExecPda: PublicKey; - instructionsHash: Uint8Array; - accountsHash: Uint8Array; - expiryOffset: number; - authPayload: Uint8Array; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const expiryBuf = Buffer.alloc(2); - expiryBuf.writeUInt16LE(params.expiryOffset); - - const parts: Uint8Array[] = [ - new Uint8Array([DISC_AUTHORIZE]), - params.instructionsHash, - params.accountsHash, - new Uint8Array(expiryBuf), - params.authPayload, - ]; - - return new TransactionInstruction({ - programId: pid, - keys: [ - { pubkey: params.payer, isSigner: true, isWritable: true }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.authorityPda, isSigner: false, isWritable: true }, - { pubkey: params.deferredExecPda, isSigner: false, isWritable: true }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }, - ], - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── ExecuteDeferred (Deferred Execution tx2) ─────────────────────── -/** - * Instruction data layout (after discriminator): - * [compact_instructions(variable)] - */ -export function createExecuteDeferredIx(params: { - payer: PublicKey; - walletPda: PublicKey; - vaultPda: PublicKey; - deferredExecPda: PublicKey; - refundDestination: PublicKey; - packedInstructions: Uint8Array; - /** Additional account metas for the inner CPI instructions */ - remainingAccounts?: { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }[]; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [ - new Uint8Array([DISC_EXECUTE_DEFERRED]), - params.packedInstructions, - ]; - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: true }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.vaultPda, isSigner: false, isWritable: true }, - { pubkey: params.deferredExecPda, isSigner: false, isWritable: true }, - { pubkey: params.refundDestination, isSigner: false, isWritable: true }, - ]; - - if (params.remainingAccounts) { - keys.push(...params.remainingAccounts); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── ReclaimDeferred ──────────────────────────────────────────────── -/** - * Closes an expired DeferredExec account and refunds rent. - * Instruction data: discriminator only (no payload). - */ -export function createReclaimDeferredIx(params: { - payer: PublicKey; - deferredExecPda: PublicKey; - refundDestination: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - - return new TransactionInstruction({ - programId: pid, - keys: [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.deferredExecPda, isSigner: false, isWritable: true }, - { pubkey: params.refundDestination, isSigner: false, isWritable: true }, - ], - data: Buffer.from([DISC_RECLAIM_DEFERRED]), - }); -} - -// ─── RevokeSession ────────────────────────────────────────────────── -/** - * Revoke a session key early (before expiry). - * Only Owner or Admin can revoke. - * Instruction data: [discriminator(1)][auth_payload(...) for Secp256r1 | empty for Ed25519] - */ -export function createRevokeSessionIx(params: { - payer: PublicKey; - walletPda: PublicKey; - adminAuthorityPda: PublicKey; - sessionPda: PublicKey; - refundDestination: PublicKey; - authPayload?: Uint8Array; - authorizerSigner?: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [new Uint8Array([DISC_REVOKE_SESSION])]; - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.adminAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.sessionPda, isSigner: false, isWritable: true }, - { pubkey: params.refundDestination, isSigner: false, isWritable: true }, - ]; - - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── Helper ────────────────────────────────────────────────────────── -function concatBytes(arrays: Uint8Array[]): Uint8Array { - const totalLen = arrays.reduce((s, a) => s + a.length, 0); - const out = new Uint8Array(totalLen); - let offset = 0; - for (const a of arrays) { - out.set(a, offset); - offset += a.length; - } - return out; -} diff --git a/sdk/solita-client/src/utils/packing.ts b/sdk/solita-client/src/utils/packing.ts deleted file mode 100644 index 2f26aa9..0000000 --- a/sdk/solita-client/src/utils/packing.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type AccountMeta } from '@solana/web3.js'; -import { createHash } from 'crypto'; - -export interface CompactInstruction { - programIdIndex: number; - accountIndexes: number[]; - data: Uint8Array; -} - -/** - * Packs a list of compact instructions into the binary format expected - * by LazorKit's Execute instruction. - * - * Format: - * [num_instructions: u8] - * for each instruction: - * [program_id_index: u8] - * [num_accounts: u8] - * [account_indexes: u8[]] - * [data_len: u16 LE] - * [data: u8[]] - */ -export function packCompactInstructions(instructions: CompactInstruction[]): Uint8Array { - const parts: Uint8Array[] = []; - parts.push(new Uint8Array([instructions.length])); - - for (const ix of instructions) { - parts.push(new Uint8Array([ix.programIdIndex, ix.accountIndexes.length])); - parts.push(new Uint8Array(ix.accountIndexes)); - const len = ix.data.length; - parts.push(new Uint8Array([len & 0xff, (len >> 8) & 0xff])); - parts.push(ix.data); - } - - return concatBytes(parts); -} - -/** - * Computes the SHA-256 hash of all account pubkeys referenced by compact instructions. - * Must match the on-chain `compute_accounts_hash`. - */ -export function computeAccountsHash( - accountMetas: AccountMeta[], - instructions: CompactInstruction[], -): Uint8Array { - const parts: Uint8Array[] = []; - for (const ix of instructions) { - parts.push(accountMetas[ix.programIdIndex].pubkey.toBytes()); - for (const idx of ix.accountIndexes) { - parts.push(accountMetas[idx].pubkey.toBytes()); - } - } - const data = concatBytes(parts); - return new Uint8Array(createHash('sha256').update(data).digest()); -} - -/** - * Computes the SHA-256 hash of packed compact instructions. - * Used for deferred execution — the hash is signed in tx1 and verified in tx2. - */ -export function computeInstructionsHash( - instructions: CompactInstruction[], -): Uint8Array { - const packed = packCompactInstructions(instructions); - return new Uint8Array(createHash('sha256').update(packed).digest()); -} - -function concatBytes(arrays: Uint8Array[]): Uint8Array { - const totalLen = arrays.reduce((s, a) => s + a.length, 0); - const out = new Uint8Array(totalLen); - let offset = 0; - for (const a of arrays) { - out.set(a, offset); - offset += a.length; - } - return out; -} diff --git a/sdk/solita-client/src/utils/pdas.ts b/sdk/solita-client/src/utils/pdas.ts deleted file mode 100644 index a38b876..0000000 --- a/sdk/solita-client/src/utils/pdas.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { PublicKey } from '@solana/web3.js'; -import { PROGRAM_ID } from '../generated'; - -export function findWalletPda( - userSeed: Uint8Array, - programId: PublicKey = PROGRAM_ID, -): [PublicKey, number] { - return PublicKey.findProgramAddressSync( - [Buffer.from('wallet'), userSeed], - programId, - ); -} - -export function findVaultPda( - walletPda: PublicKey, - programId: PublicKey = PROGRAM_ID, -): [PublicKey, number] { - return PublicKey.findProgramAddressSync( - [Buffer.from('vault'), walletPda.toBuffer()], - programId, - ); -} - -export function findAuthorityPda( - walletPda: PublicKey, - credentialIdHash: Uint8Array, - programId: PublicKey = PROGRAM_ID, -): [PublicKey, number] { - return PublicKey.findProgramAddressSync( - [Buffer.from('authority'), walletPda.toBuffer(), credentialIdHash], - programId, - ); -} - -export function findSessionPda( - walletPda: PublicKey, - sessionKey: Uint8Array, - programId: PublicKey = PROGRAM_ID, -): [PublicKey, number] { - return PublicKey.findProgramAddressSync( - [Buffer.from('session'), walletPda.toBuffer(), sessionKey], - programId, - ); -} - -export function findDeferredExecPda( - walletPda: PublicKey, - authorityPda: PublicKey, - counter: number, - programId: PublicKey = PROGRAM_ID, -): [PublicKey, number] { - const counterBuf = Buffer.alloc(4); - counterBuf.writeUInt32LE(counter); - return PublicKey.findProgramAddressSync( - [ - Buffer.from('deferred'), - walletPda.toBuffer(), - authorityPda.toBuffer(), - counterBuf, - ], - programId, - ); -} diff --git a/sdk/solita-client/src/utils/secp256r1.ts b/sdk/solita-client/src/utils/secp256r1.ts deleted file mode 100644 index 062fb02..0000000 --- a/sdk/solita-client/src/utils/secp256r1.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Connection, PublicKey } from '@solana/web3.js'; -import { createHash } from 'crypto'; -import { PROGRAM_ID } from '../generated'; - -/** - * Generates WebAuthn authenticator data for a given RP ID. - * - * Format: rpIdHash(32) + flags(1) + counter(4) = 37 bytes - * - Flags: 0x01 (User Present) - * - Counter: 0 (LazorKit uses its own odometer counter, not WebAuthn counter) - */ -export function generateAuthenticatorData(rpId: string): Uint8Array { - const rpIdHash = createHash('sha256').update(rpId).digest(); - const data = new Uint8Array(37); - data.set(rpIdHash, 0); - data[32] = 0x01; // User Present flag - // Counter bytes (33-36) stay 0 - return data; -} - -/** - * Callback interface for Secp256r1 (passkey/WebAuthn) signing. - * The SDK never touches private keys. - * - * The sign() method receives a SHA-256 challenge and must: - * 1. Build clientDataJSON: `{ type: "webauthn.get", challenge: base64url(challenge), origin: "https://", crossOrigin: false }` - * 2. Compute clientDataJsonHash = SHA256(clientDataJSON) - * 3. Sign: signature = ECDSA_SIGN(authenticatorData || clientDataJsonHash) - * 4. Return { signature (64-byte raw r||s, low-S normalized), authenticatorData, clientDataJsonHash } - */ -export interface Secp256r1Signer { - /** Compressed public key (33 bytes) */ - publicKeyBytes: Uint8Array; - /** SHA256 of the credential ID (32 bytes) — used as PDA seed */ - credentialIdHash: Uint8Array; - /** RP ID string (e.g. "lazorkit.app") */ - rpId: string; - /** - * Signs the SHA-256 challenge with the passkey. - * Returns { signature, authenticatorData, clientDataJsonHash }. - */ - sign(challenge: Uint8Array): Promise<{ - /** 64-byte raw ECDSA signature (r || s), low-S normalized */ - signature: Uint8Array; - /** WebAuthn authenticator data bytes */ - authenticatorData: Uint8Array; - /** SHA256 of the clientDataJSON */ - clientDataJsonHash: Uint8Array; - }>; -} - -/** - * Reads the current odometer counter from an on-chain authority account. - * The counter is a u32 LE at offset 8 of the AuthorityAccountHeader. - */ -export async function readAuthorityCounter( - connection: Connection, - authorityPda: PublicKey, -): Promise { - const info = await connection.getAccountInfo(authorityPda); - if (!info) throw new Error(`Authority account not found: ${authorityPda.toBase58()}`); - if (info.data.length < 12) throw new Error('Authority account data too short'); - const view = new DataView(info.data.buffer, info.data.byteOffset); - return view.getUint32(8, true); // offset 8, little-endian, u32 -} - -/** - * Builds the auth_payload bytes for a Secp256r1 operation. - * - * Layout (optimized — rpId stored on-chain, counter is u32): - * [slot(8)][counter(4)][sysvarIxIdx(1)][typeAndFlags(1)][authenticatorData(M)] - */ -export function buildAuthPayload(params: { - slot: bigint; - counter: number; - sysvarIxIndex: number; - typeAndFlags: number; - authenticatorData: Uint8Array; -}): Uint8Array { - const totalLen = 8 + 4 + 1 + 1 + params.authenticatorData.length; - const buf = Buffer.alloc(totalLen); - let offset = 0; - - buf.writeBigUInt64LE(params.slot, offset); - offset += 8; - buf.writeUInt32LE(params.counter, offset); - offset += 4; - buf.writeUInt8(params.sysvarIxIndex, offset); - offset += 1; - buf.writeUInt8(params.typeAndFlags, offset); - offset += 1; - Buffer.from(params.authenticatorData).copy(buf, offset); - - return new Uint8Array(buf); -} - -/** - * Computes the SHA-256 challenge hash that must be signed by the passkey. - * - * Hash = SHA256(discriminator || auth_payload || signed_payload || slot_le || payer || counter_le(4) || program_id) - * - * This must exactly match the on-chain `sol_sha256` call in secp256r1/mod.rs. - */ -export function buildSecp256r1Challenge(params: { - discriminator: Uint8Array; - authPayload: Uint8Array; - signedPayload: Uint8Array; - slot: bigint; - payer: PublicKey; - counter: number; - programId?: PublicKey; -}): Uint8Array { - const pid = params.programId ?? PROGRAM_ID; - const slotBuf = Buffer.alloc(8); - slotBuf.writeBigUInt64LE(params.slot); - const counterBuf = Buffer.alloc(4); - counterBuf.writeUInt32LE(params.counter); - - const hash = createHash('sha256'); - hash.update(params.discriminator); - hash.update(params.authPayload); - hash.update(params.signedPayload); - hash.update(slotBuf); - hash.update(params.payer.toBuffer()); - hash.update(counterBuf); - hash.update(pid.toBuffer()); - return new Uint8Array(hash.digest()); -} diff --git a/sdk/solita-client/src/utils/signing.ts b/sdk/solita-client/src/utils/signing.ts deleted file mode 100644 index dceb31d..0000000 --- a/sdk/solita-client/src/utils/signing.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { PublicKey, TransactionInstruction } from '@solana/web3.js'; -import { - buildAuthPayload, - buildSecp256r1Challenge, - generateAuthenticatorData, - type Secp256r1Signer, -} from './secp256r1'; -import { AUTH_TYPE_SECP256R1 } from './instructions'; - -// ─── Secp256r1 signing flow ───────────────────────────────────────── - -/** - * Full Secp256r1 signing: build auth payload, compute challenge, sign, build precompile ix. - */ -export async function signWithSecp256r1(params: { - signer: Secp256r1Signer; - discriminator: Uint8Array; - signedPayload: Uint8Array; - sysvarIxIndex: number; - slot: bigint; - counter: number; - payer: PublicKey; - programId: PublicKey; -}): Promise<{ - authPayload: Uint8Array; - precompileIx: TransactionInstruction; -}> { - const authenticatorData = generateAuthenticatorData(params.signer.rpId); - - const authPayload = buildAuthPayload({ - slot: params.slot, - counter: params.counter, - sysvarIxIndex: params.sysvarIxIndex, - typeAndFlags: 0x10, // webauthn.get + https - authenticatorData, - }); - - const challenge = buildSecp256r1Challenge({ - discriminator: params.discriminator, - authPayload, - signedPayload: params.signedPayload, - slot: params.slot, - payer: params.payer, - counter: params.counter, - programId: params.programId, - }); - - const { signature, authenticatorData: signerAuthData, clientDataJsonHash } = - await params.signer.sign(challenge); - - const precompileMessage = concatParts([signerAuthData, clientDataJsonHash]); - const precompileIx = buildSecp256r1PrecompileIx( - params.signer.publicKeyBytes, - precompileMessage, - signature, - ); - - return { authPayload, precompileIx }; -} - -// ─── Data payload builders ────────────────────────────────────────── - -/** - * AddAuthority data payload: - * [type(1)][role(1)][padding(6)][credential(32)][secp256r1Pubkey?(33)][rpIdLen?(1)][rpId?(N)] - */ -export function buildDataPayloadForAdd( - newType: number, - newRole: number, - credentialOrPubkey: Uint8Array, - secp256r1Pubkey?: Uint8Array, - rpId?: string, -): Uint8Array { - const parts: Uint8Array[] = [ - new Uint8Array([newType, newRole]), - new Uint8Array(6), // padding - credentialOrPubkey, - ]; - if (newType === AUTH_TYPE_SECP256R1 && secp256r1Pubkey) { - parts.push(secp256r1Pubkey); - if (rpId) { - const rpIdBytes = Buffer.from(rpId, 'utf-8'); - parts.push(new Uint8Array([rpIdBytes.length])); - parts.push(new Uint8Array(rpIdBytes)); - } - } - return concatParts(parts); -} - -/** - * TransferOwnership data payload: [auth_type(1)][full_auth_data] - */ -export function buildDataPayloadForTransfer( - newType: number, - credentialOrPubkey: Uint8Array, - secp256r1Pubkey?: Uint8Array, - rpId?: string, -): Uint8Array { - const parts: Uint8Array[] = [ - new Uint8Array([newType]), - credentialOrPubkey, - ]; - if (newType === AUTH_TYPE_SECP256R1 && secp256r1Pubkey) { - parts.push(secp256r1Pubkey); - if (rpId) { - const rpIdBytes = Buffer.from(rpId, 'utf-8'); - parts.push(new Uint8Array([rpIdBytes.length])); - parts.push(new Uint8Array(rpIdBytes)); - } - } - return concatParts(parts); -} - -/** - * CreateSession data payload: [session_key(32)][expires_at(8)] - */ -export function buildDataPayloadForSession( - sessionKey: Uint8Array, - expiresAt: bigint, -): Uint8Array { - const buf = new Uint8Array(40); - buf.set(sessionKey, 0); - const expiresAtBuf = Buffer.alloc(8); - expiresAtBuf.writeBigInt64LE(expiresAt); - buf.set(new Uint8Array(expiresAtBuf), 32); - return buf; -} - -// ─── Helpers ──────────────────────────────────────────────────────── - -export function concatParts(parts: Uint8Array[]): Uint8Array { - const totalLen = parts.reduce((s, a) => s + a.length, 0); - const out = new Uint8Array(totalLen); - let offset = 0; - for (const a of parts) { - out.set(a, offset); - offset += a.length; - } - return out; -} - -/** - * Builds the Secp256r1 precompile verify instruction. - * Program: Secp256r1SigVerify111111111111111111111111111 - */ -export function buildSecp256r1PrecompileIx( - publicKey: Uint8Array, - message: Uint8Array, - signature: Uint8Array, -): TransactionInstruction { - const SECP256R1_PROGRAM_ID = new PublicKey('Secp256r1SigVerify1111111111111111111111111'); - - const HEADER_SIZE = 16; - const sigOffset = HEADER_SIZE; - const pubkeyOffset = sigOffset + 64; - const msgOffset = pubkeyOffset + 33 + 1; // 1-byte alignment padding - - const data = Buffer.alloc(HEADER_SIZE + 64 + 33 + 1 + message.length); - let off = 0; - - data.writeUInt8(1, off); off += 1; - data.writeUInt8(0, off); off += 1; - data.writeUInt16LE(sigOffset, off); off += 2; - data.writeUInt16LE(0xFFFF, off); off += 2; - data.writeUInt16LE(pubkeyOffset, off); off += 2; - data.writeUInt16LE(0xFFFF, off); off += 2; - data.writeUInt16LE(msgOffset, off); off += 2; - data.writeUInt16LE(message.length, off); off += 2; - data.writeUInt16LE(0xFFFF, off); off += 2; - - Buffer.from(signature).copy(data, sigOffset); - Buffer.from(publicKey).copy(data, pubkeyOffset); - Buffer.from(message).copy(data, msgOffset); - - return new TransactionInstruction({ - programId: SECP256R1_PROGRAM_ID, - keys: [], - data, - }); -} diff --git a/sdk/solita-client/src/utils/types.ts b/sdk/solita-client/src/utils/types.ts deleted file mode 100644 index 7942298..0000000 --- a/sdk/solita-client/src/utils/types.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { PublicKey } from '@solana/web3.js'; -import type { Secp256r1Signer } from './secp256r1'; - -// ─── CreateWallet owner types ──────────────────────────────────────── - -export interface CreateWalletEd25519 { - type: 'ed25519'; - publicKey: PublicKey; -} - -export interface CreateWalletSecp256r1 { - type: 'secp256r1'; - credentialIdHash: Uint8Array; - compressedPubkey: Uint8Array; - rpId: string; -} - -/** Owner union for createWallet() */ -export type CreateWalletOwner = CreateWalletEd25519 | CreateWalletSecp256r1; - -// ─── Discriminated union signer types ───────────────────────────────── - -/** Ed25519 signer — the Keypair signs at transaction level */ -export interface Ed25519SignerConfig { - type: 'ed25519'; - publicKey: PublicKey; - /** Pre-derived authority PDA (auto-derived from publicKey if omitted) */ - authorityPda?: PublicKey; -} - -/** Secp256r1 (passkey / WebAuthn) signer */ -export interface Secp256r1SignerConfig { - type: 'secp256r1'; - signer: Secp256r1Signer; - /** Pre-derived authority PDA (auto-derived from credentialIdHash if omitted) */ - authorityPda?: PublicKey; - /** Override slot (auto-fetched from connection if omitted) */ - slotOverride?: bigint; -} - -/** Session key signer */ -export interface SessionSignerConfig { - type: 'session'; - sessionPda: PublicKey; - sessionKeyPubkey: PublicKey; -} - -/** Signer union for admin operations (authority/ownership/session management) */ -export type AdminSigner = Ed25519SignerConfig | Secp256r1SignerConfig; - -/** Signer union for execute operations (includes session keys) */ -export type ExecuteSigner = Ed25519SignerConfig | Secp256r1SignerConfig | SessionSignerConfig; - -/** Pre-computed data from authorize() needed by executeDeferredFromPayload() */ -export interface DeferredPayload { - walletPda: PublicKey; - deferredExecPda: PublicKey; - compactInstructions: { programIdIndex: number; accountIndexes: number[]; data: Uint8Array }[]; - remainingAccounts: { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }[]; -} - -// ─── Helper constructors ────────────────────────────────────────────── - -export function ed25519(publicKey: PublicKey, authorityPda?: PublicKey): Ed25519SignerConfig { - return { type: 'ed25519', publicKey, authorityPda }; -} - -export function secp256r1( - signer: Secp256r1Signer, - opts?: { authorityPda?: PublicKey; slotOverride?: bigint }, -): Secp256r1SignerConfig { - return { type: 'secp256r1', signer, ...opts }; -} - -export function session(sessionPda: PublicKey, sessionKeyPubkey: PublicKey): SessionSignerConfig { - return { type: 'session', sessionPda, sessionKeyPubkey }; -} diff --git a/sdk/solita-client/src/utils/wrapper.ts b/sdk/solita-client/src/utils/wrapper.ts deleted file mode 100644 index 83289b3..0000000 --- a/sdk/solita-client/src/utils/wrapper.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @deprecated Import from './client' instead. This file is a backward-compat shim. - * - * The old monolithic wrapper has been split into focused modules: - * - client.ts — LazorKitClient (unified API with discriminated signer types) - * - signing.ts — Secp256r1 signing helpers & data payload builders - * - compact.ts — buildCompactLayout (TransactionInstruction → CompactInstruction) - * - types.ts — Signer type definitions (AdminSigner, ExecuteSigner, etc.) - */ -export { LazorKitClient } from './client'; diff --git a/sdk/solita-client/tsconfig.json b/sdk/solita-client/tsconfig.json deleted file mode 100644 index 90fa74d..0000000 --- a/sdk/solita-client/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "declaration": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "moduleResolution": "node" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} From db8909a2ac612ec81294f3b676d9b30f92b6bdc4 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 14:30:38 +0700 Subject: [PATCH 04/13] docs: scrub solita-client references; point to @lazorkit/sdk-legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Followup to deleting sdk/solita-client. Updated: - README.md: install/usage examples + project structure now reference @lazorkit/sdk-legacy. Added a one-liner explaining the SDK's flavor-blind probing behavior so foundation + commercial users see the same DX. - DEVELOPMENT.md: removed solita-client from project structure, removed the SDK regeneration workflow (the SDK is hand-written upstream), added a note on local file: link setup. - SECURITY.md: in-scope SDK reference updated. - CHANGELOG.md: replaced the v0.1.0 line claiming Solita codegen with a pointer to the published @lazorkit/sdk-legacy. - docs/Architecture.md: removed the in-tree SDK module tree from the layout diagram, added a pointer to the sibling repo. - scripts/build-all.sh: removed step 3 (Solita SDK regeneration). Build is now Rust + IDL only. Only remaining "solita-client" mention is in the CHANGELOG entry that documents this removal — intentional. --- CHANGELOG.md | 2 +- DEVELOPMENT.md | 27 ++++++++++++++++++++------- README.md | 15 ++++++--------- SECURITY.md | 2 +- docs/Architecture.md | 18 +++++------------- scripts/build-all.sh | 19 +++++++++---------- 6 files changed, 42 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fee6a5..174077c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Odometer counter replay protection for Secp256r1 (monotonic u32 per authority) - program_id included in challenge hash (cross-program replay prevention) - rpId stored on authority account at creation (saves ~14 bytes per transaction) -- TypeScript SDK (`sdk/solita-client`) with Solita code generation +- TypeScript SDK: standardised on `@lazorkit/sdk-legacy` (lives in sibling `lazorkit-protocol` repo); the in-tree `sdk/solita-client` has been removed - Integration + security test suite (`tests-sdk/`) with 56 tests across 11 files - Benchmark script for CU and transaction size measurements - CompactInstructions accounts hash for anti-reordering protection diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 862f8d3..6591d89 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -13,12 +13,17 @@ This document outlines the standard procedures for building, deploying, and test ``` /program Rust smart contract (pinocchio, zero-copy) -/sdk/solita-client TypeScript SDK (Solita-generated + hand-written utils) -/tests-sdk Integration tests (vitest, @solana/web3.js v1) +/tests-sdk Integration tests (vitest, @lazorkit/sdk-legacy) /scripts Build/deploy automation /audits Audit reports /no-padding Custom NoPadding derive macro /assertions Custom assertion helpers + +The TypeScript SDK lives in the sibling `lazorkit-protocol` repo at +`sdk/sdk-legacy/` and is published to npm as `@lazorkit/sdk-legacy`. The +same SDK transparently handles both this build (program-v2, no fees) and +the commercial build — it probes ProtocolConfig on first use and +conditionally appends fee accounts. ``` ## Core Workflows @@ -38,8 +43,9 @@ cargo build-sbf --features devnet cargo build-sbf --features mainnet ``` -The convenience script `./scripts/build-all.sh ` builds, generates -the IDL, and regenerates the SDK in one shot. +The convenience script `./scripts/build-all.sh ` builds and +regenerates the IDL in one shot. SDK regeneration is no longer needed — +`@lazorkit/sdk-legacy` is hand-written and lives in the sibling repo. ### B. Run Rust Tests @@ -60,13 +66,20 @@ PROGRAM_ID=$(solana-keygen pubkey ../target/deploy/lazorkit_program-keypair.json shank idl -o . --out-filename idl.json -p "$PROGRAM_ID" ``` -### D. SDK Generation (using Solita) +### D. SDK + +`@lazorkit/sdk-legacy` is hand-written (no codegen) and lives in the +sibling `lazorkit-protocol` repo at `sdk/sdk-legacy/`. To use it locally: ```bash -cd sdk/solita-client && node generate.mjs +# In a sibling checkout: /Users/.../lazorkit-protocol/sdk/sdk-legacy +npm install && npm run build + +# Then in this repo's tests-sdk (already configured via `file:` link): +cd tests-sdk && npm install ``` -The generate.mjs script reads the Shank IDL, enriches it with accounts/errors/types, and runs Solita to produce TypeScript code in `src/generated/`. +Once published to npm, consumers do `npm install @lazorkit/sdk-legacy`. ### E. Running Integration Tests diff --git a/README.md b/README.md index 1cbe96d..c9f9219 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,7 @@ program/src/ Rust smart contract (pinocchio, zero-copy) auth/ Ed25519 + Secp256r1/WebAuthn authentication processor/ 9 instruction handlers state/ Account data structures (NoPadding) -sdk/solita-client/ TypeScript SDK (Solita-generated + hand-written utils) - src/generated/ Auto-generated instructions, accounts, errors - src/utils/ Instruction builders, PDA helpers, signing utils -tests-sdk/ Integration tests (vitest, 56 tests) +tests-sdk/ Integration tests (vitest, 56 tests, uses @lazorkit/sdk-legacy) docs/ Architecture, cost analysis audits/ Audit reports ``` @@ -120,14 +117,14 @@ cargo build-sbf ### Install SDK ```bash -npm install @lazorkit/solita-client +npm install @lazorkit/sdk-legacy ``` ### Create a Wallet ```typescript import { Connection } from '@solana/web3.js'; -import { LazorKitClient } from '@lazorkit/solita-client'; +import { LazorKitClient } from '@lazorkit/sdk-legacy'; import * as crypto from 'crypto'; const connection = new Connection('https://api.devnet.solana.com'); @@ -148,7 +145,7 @@ const { instructions, walletPda, vaultPda } = client.createWallet({ ### Transfer SOL ```typescript -import { secp256r1 } from '@lazorkit/solita-client'; +import { secp256r1 } from '@lazorkit/sdk-legacy'; // Just payer, wallet, signer, recipient, amount -- nothing else const { instructions } = await client.transferSol({ @@ -174,7 +171,7 @@ const { instructions } = await client.execute({ }); ``` -See [sdk/solita-client/README.md](sdk/solita-client/README.md) for full API reference. +See the [@lazorkit/sdk-legacy README](https://github.com/lazor-kit/lazorkit-protocol/tree/main/sdk/sdk-legacy) for full API reference. The same SDK transparently handles both this build (program-v2, no fees) and the commercial build (lazorkit-protocol, with fees) — it probes ProtocolConfig on first use and conditionally appends fee accounts. --- @@ -222,7 +219,7 @@ Report vulnerabilities via [SECURITY.md](SECURITY.md). |---|---| | [Architecture](docs/Architecture.md) | Account structures, security mechanisms, instruction reference | | [Costs](docs/Costs.md) | CU benchmarks, rent costs, transaction size analysis | -| [SDK API](sdk/solita-client/README.md) | TypeScript SDK reference | +| [@lazorkit/sdk-legacy](https://github.com/lazor-kit/lazorkit-protocol/tree/main/sdk/sdk-legacy) | TypeScript SDK reference | | [Development](DEVELOPMENT.md) | Build, test, deploy workflow | | [Contributing](CONTRIBUTING.md) | How to contribute | | [Security](SECURITY.md) | Vulnerability reporting | diff --git a/SECURITY.md b/SECURITY.md index 87afe53..e672c44 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,7 @@ If you discover a security vulnerability in LazorKit, please report it responsib The following are in scope: - On-chain Solana program (`program/src/`) -- TypeScript SDK (`sdk/solita-client/`) +- TypeScript SDK (`@lazorkit/sdk-legacy`, lives in sibling `lazorkit-protocol` repo) - PDA derivation and signature verification logic - Replay protection mechanisms diff --git a/docs/Architecture.md b/docs/Architecture.md index 7fb9db4..3dc4b9b 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -335,18 +335,10 @@ program/ utils.rs PDA initialization, stack_height check error.rs AuthError enum (3001-3018) entrypoint.rs Instruction routing -sdk/solita-client/ - src/ - generated/ Solita-generated instructions, accounts, errors - utils/ - instructions.ts Low-level instruction builders - client.ts LazorKitClient high-level API (unified) - types.ts Discriminated union signer types + helper constructors - signing.ts Secp256r1 signing utilities - compact.ts CompactInstruction layout builder - pdas.ts PDA derivation helpers - secp256r1.ts Challenge hash + auth payload builders - packing.ts CompactInstruction packing - errors.ts Error code mapping tests-sdk/ Integration + security tests (vitest, 56 tests) ``` + +The TypeScript SDK lives outside this repo: `@lazorkit/sdk-legacy` (in +sibling `lazorkit-protocol` repo at `sdk/sdk-legacy/`). Same SDK works +against this build (foundation, no fee) and the commercial build — +probes ProtocolConfig at runtime and conditionally appends fee accounts. diff --git a/scripts/build-all.sh b/scripts/build-all.sh index 29a9953..0f41d01 100755 --- a/scripts/build-all.sh +++ b/scripts/build-all.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Build the Rust program for a chosen cluster, derive the program ID from the -# resulting keypair, regenerate IDL + SDK against that ID. +# Build the Rust program for a chosen cluster, derive the program ID from +# the resulting keypair, regenerate IDL. # # Usage: # ./scripts/build-all.sh devnet # builds with --features devnet (FLb7...) @@ -8,12 +8,16 @@ # # After this script the .so + keypair live at target/deploy/. Deploy with: # solana program deploy target/deploy/lazorkit_program.so -u +# +# The TypeScript SDK lives in the sibling lazorkit-protocol repo at +# sdk/sdk-legacy/ and is published to npm as @lazorkit/sdk-legacy. No +# SDK regeneration step here — the SDK is hand-written and works +# unchanged against either cluster (it probes ProtocolConfig at runtime). set -e CLUSTER=$1 ROOT_DIR=$(pwd) PROGRAM_DIR="$ROOT_DIR/program" -SDK_DIR="$ROOT_DIR/sdk/solita-client" if [ "$CLUSTER" != "mainnet" ] && [ "$CLUSTER" != "devnet" ]; then echo "Usage: $0 " @@ -24,13 +28,13 @@ echo "--- 🚀 LazorKit build (cluster: $CLUSTER) ---" # Step 1: Build Rust Program with the chosen cluster feature. # This embeds the right declare_id! at compile time via assertions/src/lib.rs. -echo "[1/3] Building Rust Program (cargo build-sbf --features $CLUSTER)..." +echo "[1/2] Building Rust Program (cargo build-sbf --features $CLUSTER)..." cd "$PROGRAM_DIR" cargo build-sbf --features "$CLUSTER" # Step 2: Generate IDL using Shank, picking the program ID from the keypair # the build emitted at target/deploy/lazorkit_program-keypair.json. -echo "[2/3] Generating IDL..." +echo "[2/2] Generating IDL..." PROGRAM_ID=$(solana-keygen pubkey ../target/deploy/lazorkit_program-keypair.json) echo " resolved program ID: $PROGRAM_ID" if command -v shank &> /dev/null; then @@ -40,10 +44,5 @@ else exit 1 fi -# Step 3: Regenerate SDK with Solita. -echo "[3/3] Regenerating Solita SDK..." -cd "$SDK_DIR" -node generate.mjs - echo "--- ✅ Done ($CLUSTER) ---" echo "Deploy: solana program deploy ../target/deploy/lazorkit_program.so -u $([ "$CLUSTER" = "mainnet" ] && echo m || echo d)" From 85abe826c943baf1cbfff86e5c054674f05e2076 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 14:42:04 +0700 Subject: [PATCH 05/13] test(actions): E2E session-action enforcement against live validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 vitest cases dogfooding @lazorkit/sdk-legacy's Actions builder against program-v2's session enforcement engine. All pass against solana-test-validator with the foundation binary loaded. Coverage: - session without actions = unrestricted (baseline) - ProgramWhitelist: allow whitelisted, reject non-whitelisted (3021) - ProgramBlacklist: allow non-blacklisted, reject blacklisted (3022) - SolMaxPerTx: allow at-cap, reject over-cap (3023) - SolLimit (lifetime): allow within budget, reject when exhausted (3024), then accept exact remaining - Combined ProgramWhitelist + SolMaxPerTx: both rules enforced Asserts vault-balance delta rather than recipient balance (recipient is the test payer, an existing funded account, to sidestep the rent-exempt minimum that fresh accounts hit on a tiny SOL transfer). Action checks fire before the inner CPI, so this doesn't weaken what's being tested. Also fixes tests/common.ts to resolve PROGRAM_ID dynamically: 1. PROGRAM_ID env var (CI override) 2. Pubkey of target/deploy/lazorkit_program-keypair.json (matches what `npm run validator:start` loads the program at) 3. FLb7… fallback (typecheck-only) Previously hardcoded to FLb7…, which broke for any locally built binary because cargo build-sbf generates a fresh keypair on first build. LazorKitClient is constructed with `new LazorKitClient(connection, PROGRAM_ID)` to override its URL-based auto-inference (which defaults localhost to the commercial 4h3X… ID). Verified end-to-end: built program-v2 SBF binary, ran solana-test-validator, ran `npx vitest run tests/12-actions.test.ts` → 9/9 pass in ~9 seconds. --- tests-sdk/tests/12-actions.test.ts | 316 +++++++++++++++++++++++++++++ tests-sdk/tests/common.ts | 35 +++- 2 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 tests-sdk/tests/12-actions.test.ts diff --git a/tests-sdk/tests/12-actions.test.ts b/tests-sdk/tests/12-actions.test.ts new file mode 100644 index 0000000..78e314a --- /dev/null +++ b/tests-sdk/tests/12-actions.test.ts @@ -0,0 +1,316 @@ +/** + * Session action enforcement (E2E). + * + * P1c added the action enforcement engine on-chain (program-v2 has been + * exercising it via litesvm Rust tests). This file is the TypeScript-side + * dogfood: every assertion goes through @lazorkit/sdk-legacy's Actions + * builder and serialiser, then submits a real tx and watches what the + * program does to it. + * + * Coverage: + * - ProgramWhitelist: allow whitelisted CPI, reject non-whitelisted (3021) + * - ProgramBlacklist: reject blacklisted CPI (3022) + * - SolMaxPerTx: allow ≤ max, reject > max (3023) + * - SolLimit (lifetime): allow within budget, reject after exhaust (3024) + * - Bonus: session WITHOUT actions is unrestricted (sanity baseline) + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { + Keypair, + PublicKey, + SystemProgram, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; +import * as crypto from 'crypto'; +import { + setupTest, + sendTx, + sendTxExpectError, + getSlot, + PROGRAM_ID, + type TestContext, +} from './common'; +import { Actions, LazorKitClient, ed25519, session } from '@lazorkit/sdk-legacy'; + +// MEMO program — innocuous CPI target for whitelist tests +const MEMO_PROGRAM_ID = new PublicKey( + 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr', +); + +describe('Session Actions (E2E enforcement)', () => { + let ctx: TestContext; + let client: LazorKitClient; + + let walletPda: PublicKey; + let vaultPda: PublicKey; + let ownerKp: Keypair; + let ownerAuthPda: PublicKey; + + beforeAll(async () => { + ctx = await setupTest(); + // Pass PROGRAM_ID explicitly — sdk-legacy's URL-based auto-infer + // defaults localhost to 4h3X… (commercial devnet), but the validator + // here loads program-v2's foundation binary at PROGRAM_ID (resolved + // from target/deploy/lazorkit_program-keypair.json). + client = new LazorKitClient(ctx.connection, PROGRAM_ID); + + ownerKp = Keypair.generate(); + const userSeed = crypto.randomBytes(32); + + const result = await client.createWallet({ + payer: ctx.payer.publicKey, + userSeed, + owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, + }); + walletPda = result.walletPda; + vaultPda = result.vaultPda; + ownerAuthPda = result.authorityPda; + + await sendTx(ctx, result.instructions); + + // Fund the vault generously so spending caps are the constraint, not balance + const sig = await ctx.connection.requestAirdrop( + vaultPda, + 10 * LAMPORTS_PER_SOL, + ); + await ctx.connection.confirmTransaction(sig, 'confirmed'); + }); + + /** + * Helper: create a session with the given actions, return its (sessionPda, sessionKp). + */ + async function createSessionWithActions( + actions: Parameters[0]['actions'], + ): Promise<{ sessionPda: PublicKey; sessionKp: Keypair }> { + const sessionKp = Keypair.generate(); + const currentSlot = await getSlot(ctx); + const expiresAt = currentSlot + 9000n; // ~1h + + const { instructions, sessionPda } = await client.createSession({ + payer: ctx.payer.publicKey, + walletPda, + adminSigner: ed25519(ownerKp.publicKey, ownerAuthPda), + sessionKey: sessionKp.publicKey, + expiresAt, + actions, + }); + await sendTx(ctx, instructions, [ownerKp]); + + return { sessionPda, sessionKp }; + } + + /** + * Helper: build an execute tx that transfers `lamports` from vault to the + * test payer (an existing funded account, so SystemProgram.transfer doesn't + * trip the rent-exempt minimum check that fresh accounts hit). The action + * enforcement we're testing kicks in BEFORE the inner transfer runs, so + * the recipient choice doesn't affect what we're verifying. + */ + async function buildVaultTransferIxs( + sessionPda: PublicKey, + sessionKey: PublicKey, + lamports: number, + ): Promise<{ ixs: Awaited>['instructions']; recipient: PublicKey }> { + const recipient = ctx.payer.publicKey; + const { instructions } = await client.execute({ + payer: ctx.payer.publicKey, + walletPda, + signer: session(sessionPda, sessionKey), + instructions: [ + SystemProgram.transfer({ + fromPubkey: vaultPda, + toPubkey: recipient, + lamports, + }), + ], + }); + return { ixs: instructions, recipient }; + } + + /** Snapshot vault balance, run sendTx, return vault delta (positive = outflow). */ + async function txAndVaultDelta( + ixs: Awaited>['instructions'], + sessionKp: Keypair, + ): Promise { + const before = await ctx.connection.getBalance(vaultPda); + await sendTx(ctx, ixs, [sessionKp]); + const after = await ctx.connection.getBalance(vaultPda); + return before - after; + } + + // ─── Baseline ────────────────────────────────────────────────────── + + it('session without actions is unrestricted (baseline)', async () => { + // No actions arg → unrestricted session, any execute should pass + const { sessionPda, sessionKp } = await createSessionWithActions(undefined); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 500_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(500_000); + }); + + // ─── ProgramWhitelist (action type 10) ───────────────────────────── + + it('ProgramWhitelist: allows CPI to whitelisted program', async () => { + // Whitelist System Program → SOL transfer (which CPIs to System Program) should succeed + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.programWhitelist(SystemProgram.programId), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 300_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(300_000); + }); + + it('ProgramWhitelist: rejects CPI to non-whitelisted program (3021)', async () => { + // Whitelist ONLY Memo program → SOL transfer (System Program CPI) must fail + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.programWhitelist(MEMO_PROGRAM_ID), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 100_000, + ); + // 3021 = ActionProgramNotWhitelisted + await sendTxExpectError(ctx, ixs, [sessionKp], 3021); + }); + + // ─── ProgramBlacklist (action type 11) ───────────────────────────── + + it('ProgramBlacklist: rejects CPI to blacklisted program (3022)', async () => { + // Blacklist System Program → SOL transfer must fail + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.programBlacklist(SystemProgram.programId), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 100_000, + ); + // 3022 = ActionProgramBlacklisted + await sendTxExpectError(ctx, ixs, [sessionKp], 3022); + }); + + it('ProgramBlacklist: allows CPI to non-blacklisted program', async () => { + // Blacklist Memo → SOL transfer (System Program CPI) is fine + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.programBlacklist(MEMO_PROGRAM_ID), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 200_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(200_000); + }); + + // ─── SolMaxPerTx (action type 3) ─────────────────────────────────── + + it('SolMaxPerTx: allows transfer at the cap', async () => { + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.solMaxPerTx(500_000n), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 500_000, // exactly the cap + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(500_000); + }); + + it('SolMaxPerTx: rejects transfer above the cap (3023)', async () => { + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.solMaxPerTx(500_000n), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 500_001, // 1 lamport over + ); + // 3023 = ActionSolMaxPerTxExceeded + await sendTxExpectError(ctx, ixs, [sessionKp], 3023); + }); + + // ─── SolLimit (lifetime cap, action type 1) ──────────────────────── + + it('SolLimit: allows spending within lifetime budget then rejects when exhausted (3024)', async () => { + // Lifetime budget = 1_000_000. First tx spends 700k → OK. + // Second tx tries 400k → would exceed remaining 300k → reject 3024. + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.solLimit(1_000_000n), + ]); + + // 1st: spend 700_000 — fits + { + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 700_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(700_000); + } + + // 2nd: spend 400_000 — exceeds remaining 300_000 → 3024 + { + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 400_000, + ); + // 3024 = ActionSolLimitExceeded + await sendTxExpectError(ctx, ixs, [sessionKp], 3024); + } + + // 3rd: spend exactly remaining 300_000 — should succeed + { + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 300_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(300_000); + } + }); + + // ─── Combined actions ────────────────────────────────────────────── + + it('Combined: ProgramWhitelist + SolMaxPerTx both enforced', async () => { + // Whitelist System Program, cap per-tx at 250_000 + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.programWhitelist(SystemProgram.programId), + Actions.solMaxPerTx(250_000n), + ]); + + // Within both rules → OK + { + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 250_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(250_000); + } + + // Exceed SolMaxPerTx but System Program allowed → 3023 + { + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 250_001, + ); + await sendTxExpectError(ctx, ixs, [sessionKp], 3023); + } + }); +}); diff --git a/tests-sdk/tests/common.ts b/tests-sdk/tests/common.ts index 7886587..d3fb124 100644 --- a/tests-sdk/tests/common.ts +++ b/tests-sdk/tests/common.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { Connection, Keypair, @@ -9,7 +11,38 @@ import { type Signer, } from '@solana/web3.js'; -export const PROGRAM_ID = new PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao'); +/** + * Resolve the program ID the test suite should target. Order: + * + * 1. `PROGRAM_ID` env var (CI / explicit override) + * 2. The pubkey of `target/deploy/lazorkit_program-keypair.json` — this is + * the address `solana-test-validator --bpf-program $(solana-keygen pubkey ...)` + * loaded the binary at, so PDA derivations on the client side match + * what the program sees at runtime. + * 3. Foundation devnet fallback (`FLb7…`) — used when neither env nor + * keypair file exist. Won't actually work end-to-end without a real + * validator setup, but lets type-check / static tooling proceed. + * + * Prior to this, PROGRAM_ID was hardcoded to FLb7 — broken for any locally + * built binary because cargo build-sbf generates a fresh keypair on first + * build (unless one is already present at the target path). + */ +function loadProgramId(): PublicKey { + if (process.env.PROGRAM_ID) { + return new PublicKey(process.env.PROGRAM_ID); + } + const keypairPath = path.resolve( + __dirname, + '../../target/deploy/lazorkit_program-keypair.json', + ); + if (fs.existsSync(keypairPath)) { + const secret = JSON.parse(fs.readFileSync(keypairPath, 'utf-8')); + return Keypair.fromSecretKey(new Uint8Array(secret)).publicKey; + } + return new PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao'); +} + +export const PROGRAM_ID = loadProgramId(); export const RPC_URL = process.env.RPC_URL || 'http://127.0.0.1:8899'; export interface TestContext { From 07bc8241a4ea96030222bc2a5de5475e42ebbaa3 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 15:01:10 +0700 Subject: [PATCH 06/13] fix(tests-sdk): pass PROGRAM_ID explicitly to LazorKitClient constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdk-legacy's LazorKitClient infers programId from the RPC URL when the second arg is omitted, defaulting localhost to the commercial devnet ID (4h3X…). Tests target the program-v2 binary loaded at the keypair pubkey, so the inference returns the wrong ID and all txs fail with "Attempt to load a program that does not exist". Pass PROGRAM_ID explicitly across all 9 test files. Also added the PROGRAM_ID import to 02-authority, 03-execute, 04-session, 07-e2e, 09-permissions, 10-session-execute (the others already had it). After this fix, vitest results against a live local validator: 35 passed | 28 failed | 2 skipped (65 total) The 28 remaining failures are ALL Secp256r1 paths. Root cause: program-v2's on-chain auth code still uses the OLD typeAndFlags format (extracts a single byte from auth_payload[13] and reconstructs clientDataJson on-chain), while sdk-legacy's mock signer uses the NEW format (embeds full clientDataJson directly in the payload). lazorkit-protocol's auth was upgraded to the new format; program-v2's wasn't ported. Fixing this requires porting lazorkit-protocol/program/src/auth/secp256r1/ to program-v2 — substantial change with audit attention. Tracked as follow-up. Ed25519 paths all pass. The 9 new E2E action tests (12-actions.test.ts) all pass since they use Ed25519 admin signers. --- tests-sdk/tests/01-wallet.test.ts | 2 +- tests-sdk/tests/02-authority.test.ts | 3 ++- tests-sdk/tests/03-execute.test.ts | 4 ++-- tests-sdk/tests/04-session.test.ts | 3 ++- tests-sdk/tests/07-e2e.test.ts | 4 ++-- tests-sdk/tests/09-permissions.test.ts | 3 ++- tests-sdk/tests/10-session-execute.test.ts | 3 ++- tests-sdk/tests/11-security.test.ts | 2 +- 8 files changed, 14 insertions(+), 10 deletions(-) diff --git a/tests-sdk/tests/01-wallet.test.ts b/tests-sdk/tests/01-wallet.test.ts index bb17233..4921dc5 100644 --- a/tests-sdk/tests/01-wallet.test.ts +++ b/tests-sdk/tests/01-wallet.test.ts @@ -16,7 +16,7 @@ describe('CreateWallet', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); }); it('creates a wallet with Ed25519 owner', async () => { diff --git a/tests-sdk/tests/02-authority.test.ts b/tests-sdk/tests/02-authority.test.ts index dc75989..6be97ff 100644 --- a/tests-sdk/tests/02-authority.test.ts +++ b/tests-sdk/tests/02-authority.test.ts @@ -5,6 +5,7 @@ import { setupTest, sendTx, sendTxExpectError, + PROGRAM_ID, type TestContext, } from './common'; import { generateMockSecp256r1Key, createMockSigner } from './secp256r1Utils'; @@ -25,7 +26,7 @@ describe('Authority Management', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); }); describe('Ed25519 admin flow', () => { diff --git a/tests-sdk/tests/03-execute.test.ts b/tests-sdk/tests/03-execute.test.ts index 832edea..c64289f 100644 --- a/tests-sdk/tests/03-execute.test.ts +++ b/tests-sdk/tests/03-execute.test.ts @@ -6,7 +6,7 @@ import { LAMPORTS_PER_SOL, } from '@solana/web3.js'; import * as crypto from 'crypto'; -import { setupTest, sendTx, type TestContext } from './common'; +import { setupTest, sendTx, PROGRAM_ID, type TestContext } from './common'; import { generateMockSecp256r1Key, createMockSigner } from './secp256r1Utils'; import { LazorKitClient, @@ -20,7 +20,7 @@ describe('Execute', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); }); describe('Ed25519 Execute', () => { diff --git a/tests-sdk/tests/04-session.test.ts b/tests-sdk/tests/04-session.test.ts index dbd0480..4ee4933 100644 --- a/tests-sdk/tests/04-session.test.ts +++ b/tests-sdk/tests/04-session.test.ts @@ -3,6 +3,7 @@ import { Keypair } from '@solana/web3.js'; import * as crypto from 'crypto'; import { setupTest, + PROGRAM_ID, sendTx, sendTxExpectError, getSlot, @@ -20,7 +21,7 @@ describe('CreateSession', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); diff --git a/tests-sdk/tests/07-e2e.test.ts b/tests-sdk/tests/07-e2e.test.ts index 3c59492..103c8c5 100644 --- a/tests-sdk/tests/07-e2e.test.ts +++ b/tests-sdk/tests/07-e2e.test.ts @@ -5,7 +5,7 @@ import { LAMPORTS_PER_SOL, } from '@solana/web3.js'; import * as crypto from 'crypto'; -import { setupTest, sendTx, getSlot, type TestContext } from './common'; +import { setupTest, sendTx, PROGRAM_ID, getSlot, type TestContext } from './common'; import { generateMockSecp256r1Key, createMockSigner } from './secp256r1Utils'; import { LazorKitClient, @@ -44,7 +44,7 @@ describe('E2E Company Workflow', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); ceoKey = await generateMockSecp256r1Key('company.com'); adminKp = Keypair.generate(); spenderKey = await generateMockSecp256r1Key('company.com'); diff --git a/tests-sdk/tests/09-permissions.test.ts b/tests-sdk/tests/09-permissions.test.ts index 9d94d7a..ba31775 100644 --- a/tests-sdk/tests/09-permissions.test.ts +++ b/tests-sdk/tests/09-permissions.test.ts @@ -13,6 +13,7 @@ import { Keypair, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js'; import * as crypto from 'crypto'; import { setupTest, + PROGRAM_ID, sendTx, sendTxExpectError, getSlot, @@ -44,7 +45,7 @@ describe('Permission Boundaries', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); // Create wallet with Ed25519 owner ownerKp = Keypair.generate(); diff --git a/tests-sdk/tests/10-session-execute.test.ts b/tests-sdk/tests/10-session-execute.test.ts index 45c6ab1..9d9aa9d 100644 --- a/tests-sdk/tests/10-session-execute.test.ts +++ b/tests-sdk/tests/10-session-execute.test.ts @@ -16,6 +16,7 @@ import { import * as crypto from 'crypto'; import { setupTest, + PROGRAM_ID, sendTx, sendTxExpectError, getSlot, @@ -38,7 +39,7 @@ describe('Session Execute', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); diff --git a/tests-sdk/tests/11-security.test.ts b/tests-sdk/tests/11-security.test.ts index 269eb75..c73e55a 100644 --- a/tests-sdk/tests/11-security.test.ts +++ b/tests-sdk/tests/11-security.test.ts @@ -49,7 +49,7 @@ describe('Security', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); }); // ─── Counter increment verification ───────────────────────────── From 1a347a02deac8908cd3b5489ec52ca8978977d5b Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 17:31:25 +0700 Subject: [PATCH 07/13] feat(auth): port Secp256r1 auth to clientDataJSON-embedding format Byte-identical with lazorkit-protocol/program/src/auth/secp256r1/. Replaces the older typeAndFlags format (which reconstructed clientDataJSON server-side from a single byte at auth_payload[13]) with the format that embeds the full raw clientDataJSON in the auth payload. Required for slot-share strategy: a wallet created on either binary (commercial or foundation) must remain verifiable after binary swap. Both binaries now share the same auth verification logic + on-chain authority account layout. Verification: - 58/65 vitest E2E tests pass against live validator (up from 12/65 before port). The 7 remaining failures are in 08-deferred.test.ts and reflect a test-side bug (missing expiryBuf in signedPayload), addressed in P5.3. --- program/src/auth/secp256r1/introspection.rs | 256 +++++++- program/src/auth/secp256r1/mod.rs | 214 ++++--- program/src/auth/secp256r1/webauthn.rs | 623 +++++++++++++++++--- 3 files changed, 924 insertions(+), 169 deletions(-) diff --git a/program/src/auth/secp256r1/introspection.rs b/program/src/auth/secp256r1/introspection.rs index a8ae963..ea95526 100644 --- a/program/src/auth/secp256r1/introspection.rs +++ b/program/src/auth/secp256r1/introspection.rs @@ -61,12 +61,18 @@ impl Secp256r1SignatureOffsets { } /// Verify the secp256r1 instruction data contains the expected signature and -/// public key. This also validates that the secp256r1 precompile offsets point -/// to the expected locations, ensuring proper data alignment. +/// public key. Also validates that the secp256r1 precompile offsets point to +/// the expected locations, ensuring proper data alignment. +/// +/// The expected precompile message is passed as TWO slices — the +/// authenticator_data and the clientDataJSON hash — which are concatenated +/// by the on-chain secp256r1 precompile as its signed message. Accepting +/// two slices here lets the caller skip a Vec allocation for the concat. pub fn verify_secp256r1_instruction_data( instruction_data: &[u8], expected_pubkey: &[u8; 33], - expected_message: &[u8], + auth_data: &[u8], + client_data_hash: &[u8; 32], ) -> Result<(), ProgramError> { // Minimum check: must have at least the header and offsets if instruction_data.len() < DATA_START { @@ -111,25 +117,257 @@ pub fn verify_secp256r1_instruction_data( if offsets.message_data_offset as usize != MESSAGE_DATA_OFFSET { return Err(AuthError::InvalidInstruction.into()); } - if offsets.message_data_size as usize != expected_message.len() { + let expected_msg_len = auth_data.len() + client_data_hash.len(); + if offsets.message_data_size as usize != expected_msg_len { return Err(AuthError::InvalidInstruction.into()); } // Dynamic length check: instruction must contain the full message - if instruction_data.len() < MESSAGE_DATA_OFFSET + expected_message.len() { + if instruction_data.len() < MESSAGE_DATA_OFFSET + expected_msg_len { return Err(AuthError::InvalidInstruction.into()); } let pubkey_data = &instruction_data [PUBKEY_DATA_OFFSET..PUBKEY_DATA_OFFSET + COMPRESSED_PUBKEY_SERIALIZED_SIZE]; - let message_data = - &instruction_data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + expected_message.len()]; - if pubkey_data != expected_pubkey { return Err(AuthError::InvalidPubkey.into()); } - if message_data != expected_message { + + // Compare the precompile's message area against the two caller-supplied + // slices piecewise — no concat, no allocation. + let msg_auth = &instruction_data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + auth_data.len()]; + if msg_auth != auth_data { + return Err(AuthError::InvalidMessageHash.into()); + } + let hash_start = MESSAGE_DATA_OFFSET + auth_data.len(); + let msg_hash = &instruction_data[hash_start..hash_start + client_data_hash.len()]; + if msg_hash != client_data_hash { return Err(AuthError::InvalidMessageHash.into()); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: build valid secp256r1 precompile instruction data with the standard layout. + fn build_precompile_ix_data( + pubkey: &[u8; 33], + signature: &[u8; 64], + message: &[u8], + ) -> Vec { + let total_len = DATA_START + 64 + 33 + 1 + message.len(); + let mut data = vec![0u8; total_len]; + + // Header + data[0] = 1; // num_signatures + data[1] = 0; // padding + + // Offsets (little-endian) + data[2..4].copy_from_slice(&(SIGNATURE_DATA_OFFSET as u16).to_le_bytes()); + data[4..6].copy_from_slice(&0xFFFFu16.to_le_bytes()); // sig ix index + data[6..8].copy_from_slice(&(PUBKEY_DATA_OFFSET as u16).to_le_bytes()); + data[8..10].copy_from_slice(&0xFFFFu16.to_le_bytes()); // pubkey ix index + data[10..12].copy_from_slice(&(MESSAGE_DATA_OFFSET as u16).to_le_bytes()); + data[12..14].copy_from_slice(&(message.len() as u16).to_le_bytes()); // msg size + data[14..16].copy_from_slice(&0xFFFFu16.to_le_bytes()); // msg ix index + + // Data + data[SIGNATURE_DATA_OFFSET..SIGNATURE_DATA_OFFSET + 64].copy_from_slice(signature); + data[PUBKEY_DATA_OFFSET..PUBKEY_DATA_OFFSET + 33].copy_from_slice(pubkey); + // Byte at offset 113 is alignment padding (zero) + data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + message.len()] + .copy_from_slice(message); + + data + } + + #[test] + fn test_verify_valid_instruction_data() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_ok()); + } + + #[test] + fn test_verify_variable_length_message() { + // Mode 1 messages are authenticatorData(37+) + clientDataJsonHash(32) = 69+ bytes. + // We split into the two halves exactly like the caller does post-refactor. + let pubkey = [0x03; 33]; + let signature = [0xCD; 64]; + let message = [0x22; 69]; + + let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + let auth_data: &[u8] = &message[..37]; + let client_data_hash: &[u8; 32] = &message[37..].try_into().unwrap(); + assert!( + verify_secp256r1_instruction_data(&ix_data, &pubkey, auth_data, client_data_hash) + .is_ok() + ); + } + + #[test] + fn test_verify_rejects_wrong_pubkey() { + let pubkey = [0x02; 33]; + let wrong_pubkey = [0x03; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + let err = + verify_secp256r1_instruction_data(&ix_data, &wrong_pubkey, &[], &message).unwrap_err(); + assert_eq!(err, AuthError::InvalidPubkey.into()); + } + + #[test] + fn test_verify_rejects_wrong_message() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + let wrong_message = [0x22; 32]; + + let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + let err = + verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &wrong_message).unwrap_err(); + assert_eq!(err, AuthError::InvalidMessageHash.into()); + } + + #[test] + fn test_verify_rejects_zero_signatures() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + ix_data[0] = 0; // zero signatures + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_multiple_signatures() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + ix_data[0] = 2; // two signatures + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_cross_instruction_sig_index() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Set signature_instruction_index to 0 instead of 0xFFFF + ix_data[4..6].copy_from_slice(&0u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_cross_instruction_pubkey_index() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Set public_key_instruction_index to 1 instead of 0xFFFF + ix_data[8..10].copy_from_slice(&1u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_cross_instruction_msg_index() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Set message_instruction_index to 0 instead of 0xFFFF + ix_data[14..16].copy_from_slice(&0u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_wrong_pubkey_offset() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Tamper pubkey_offset to point elsewhere + ix_data[6..8].copy_from_slice(&200u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_wrong_message_offset() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Tamper message_data_offset + ix_data[10..12].copy_from_slice(&50u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_wrong_signature_offset() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Tamper signature_offset + ix_data[2..4].copy_from_slice(&100u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_message_size_mismatch() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Set message_data_size to wrong value + ix_data[12..14].copy_from_slice(&64u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_too_short_data() { + let pubkey = [0x02; 33]; + let message = [0x11; 32]; + + // Only 2 bytes — way too short + assert!(verify_secp256r1_instruction_data(&[0x01, 0x00], &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_truncated_message_area() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Truncate — remove last 10 bytes so message area is incomplete + ix_data.truncate(ix_data.len() - 10); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_offsets_constants_are_consistent() { + assert_eq!(DATA_START, 16); // 2 header + 14 offsets + assert_eq!(SIGNATURE_DATA_OFFSET, 16); + assert_eq!(PUBKEY_DATA_OFFSET, 16 + 64); // 80 + assert_eq!(MESSAGE_DATA_OFFSET, 80 + 33 + 1); // 114 + } +} diff --git a/program/src/auth/secp256r1/mod.rs b/program/src/auth/secp256r1/mod.rs index a6bafe8..ecc8363 100644 --- a/program/src/auth/secp256r1/mod.rs +++ b/program/src/auth/secp256r1/mod.rs @@ -15,9 +15,7 @@ pub mod introspection; pub mod webauthn; use self::introspection::verify_secp256r1_instruction_data; -use self::webauthn::{ - reconstruct_client_data_json, AuthDataParser, ClientDataJsonReconstructionParams, -}; +use self::webauthn::{base64url_encode_no_pad, extract_top_level_string_field, AuthDataParser}; use crate::auth::traits::Authenticator; use crate::utils::get_stack_height; @@ -31,11 +29,20 @@ pub struct Secp256r1Authenticator; impl Authenticator for Secp256r1Authenticator { /// Authenticates a Secp256r1 signature (WebAuthn/Passkeys). /// - /// Auth payload layout: - /// [slot(8)] [counter(4)] [sysvarIxIdx(1)] [flags(1)] [authenticatorData(M)] + /// Auth payload layout (raw clientDataJSON — the only supported mode): + /// [slot(8)] [counter(4)] [sysvarIxIdx(1)] [_reserved(1)] + /// [authDataLen(2 LE)] [authenticatorData(M)] + /// [cdjLen(2 LE)] [clientDataJson(N)] + /// + /// rpIdHash is pre-computed at authority creation and stored on the + /// Authority account, so every Execute saves one sol_sha256 syscall and + /// the Authority account size is fixed (145 bytes for Secp256r1). /// - /// rpId is stored on the authority account (not in the payload). - /// Counter is a program-controlled u32 odometer. Client must submit `on_chain_counter + 1`. + /// Counter is a program-controlled u32 odometer. Client must submit + /// `on_chain_counter + 1`. + /// + /// Programmatic/bot signing should use Ed25519 authorities instead — + /// Secp256r1 is passkeys-only. fn authenticate( &self, accounts: &[AccountInfo], @@ -45,26 +52,23 @@ impl Authenticator for Secp256r1Authenticator { discriminator: &[u8], program_id: &Pubkey, ) -> Result<(), ProgramError> { - // Minimum: slot(8) + counter(4) + sysvarIxIdx(1) + flags(1) = 14 - if auth_payload.len() < 14 { + // Minimum: slot(8) + counter(4) + sysvarIxIdx(1) + reserved(1) = 14, + // plus authDataLen(2) + cdjLen(2) = 18 before any payload bytes. + if auth_payload.len() < 18 { return Err(AuthError::InvalidAuthorityPayload.into()); } let slot = u64::from_le_bytes(auth_payload[0..8].try_into().unwrap()); let submitted_counter = u32::from_le_bytes(auth_payload[8..12].try_into().unwrap()); let sysvar_ix_index = auth_payload[12] as usize; - - let reconstruction_params = ClientDataJsonReconstructionParams { - type_and_flags: auth_payload[13], - }; - let authenticator_data_raw: &[u8] = &auth_payload[14..]; + // auth_payload[13] reserved (carried over from legacy mode byte). // Anti-CPI check: prevent cross-program authentication attacks if get_stack_height() > 1 { return Err(AuthError::PermissionDenied.into()); } - // Validate slot freshness using Clock sysvar (replaces SlotHashes lookup) + // Validate slot freshness using Clock sysvar let clock = Clock::get()?; let current_slot = clock.slot; if slot > current_slot { @@ -84,55 +88,33 @@ impl Authenticator for Secp256r1Authenticator { }; // --- Odometer validation --- - // The client must submit exactly `stored_counter + 1`. - // This decouples replay protection from the WebAuthn hardware counter, - // which is unreliable for synced passkeys (iCloud, Google). let expected_counter = header.counter.wrapping_add(1); if submitted_counter != expected_counter { return Err(AuthError::SignatureReused.into()); } - // Secp256r1 on-chain data layout: - // [Header(48)] [credential_id_hash(32)] [Pubkey(33)] [rpIdLen(1)] [rpId(N)] - let pubkey_offset = header_size + 32; // skip credential_id_hash - if auth_data.len() < pubkey_offset + 33 { + // Secp256r1 on-chain data layout (fixed 145 bytes total): + // [Header(48)] [credential_id_hash(32)] [Pubkey(33)] [rpIdHash(32)] + let pubkey_offset = header_size + 32; // 80 + let rp_id_hash_offset = pubkey_offset + 33; // 113 + if auth_data.len() < rp_id_hash_offset + 32 { return Err(AuthError::InvalidAuthorityPayload.into()); } - - // Read rpId from authority account data (stored at creation time) - let rp_id_len_offset = pubkey_offset + 33; - if auth_data.len() < rp_id_len_offset + 1 { - return Err(AuthError::InvalidAuthorityPayload.into()); - } - let rp_id_len = auth_data[rp_id_len_offset] as usize; - let rp_id_offset = rp_id_len_offset + 1; - if auth_data.len() < rp_id_offset + rp_id_len { - return Err(AuthError::InvalidAuthorityPayload.into()); - } - let rp_id = &auth_data[rp_id_offset..rp_id_offset + rp_id_len]; - - #[allow(unused_assignments)] - let mut computed_rp_id_hash = [0u8; 32]; - #[cfg(target_os = "solana")] - unsafe { - let _res = pinocchio::syscalls::sol_sha256( - [rp_id].as_ptr() as *const u8, - 1, - computed_rp_id_hash.as_mut_ptr(), - ); - } - #[cfg(not(target_os = "solana"))] - { - computed_rp_id_hash = [0u8; 32]; - } + let stored_rp_id_hash = &auth_data[rp_id_hash_offset..rp_id_hash_offset + 32]; let payer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; if !payer.is_signer() { return Err(ProgramError::MissingRequiredSignature); } - // Build challenge hash: - // SHA256(discriminator || auth_payload || signed_payload || slot || payer || counter || program_id) + // Challenge hash: + // SHA256(discriminator || auth_payload[..14] || signed_payload + // || payer || counter || program_id) + // + // Only the 14-byte fixed prefix of auth_payload is included because the + // remainder contains clientDataJSON — which is produced by the + // authenticator *after* signing the challenge, so it can't be in the + // hash input. let counter_bytes = expected_counter.to_le_bytes(); #[allow(unused_assignments)] let mut hasher = [0u8; 32]; @@ -141,57 +123,93 @@ impl Authenticator for Secp256r1Authenticator { let _res = pinocchio::syscalls::sol_sha256( [ discriminator, - auth_payload, + &auth_payload[..14], signed_payload, - &slot.to_le_bytes(), payer.key().as_ref(), &counter_bytes, program_id.as_ref(), ] .as_ptr() as *const u8, - 7, + 6, hasher.as_mut_ptr(), ); } #[cfg(not(target_os = "solana"))] { - let _ = signed_payload; - let _ = discriminator; - let _ = counter_bytes; - let _ = program_id; + let _ = (signed_payload, discriminator, counter_bytes, program_id); hasher = [0u8; 32]; } - let client_data_json = reconstruct_client_data_json(&reconstruction_params, rp_id, &hasher); + // --- Parse Mode 1 payload: authenticatorData + clientDataJSON --- + let auth_data_len = + u16::from_le_bytes(auth_payload[14..16].try_into().unwrap()) as usize; + if auth_payload.len() < 16 + auth_data_len + 2 { + return Err(AuthError::InvalidAuthorityPayload.into()); + } + let authenticator_data_raw = &auth_payload[16..16 + auth_data_len]; + + let cdj_len_offset = 16 + auth_data_len; + let cdj_len = + u16::from_le_bytes(auth_payload[cdj_len_offset..cdj_len_offset + 2].try_into().unwrap()) + as usize; + let cdj_offset = cdj_len_offset + 2; + // L2: strict length — trailing bytes after cdj are not covered by + // challenge hash or precompile message, so they're rejected. + if cdj_len == 0 || auth_payload.len() != cdj_offset + cdj_len { + return Err(AuthError::InvalidAuthorityPayload.into()); + } + let raw_client_data_json = &auth_payload[cdj_offset..cdj_offset + cdj_len]; + + // L1: We intentionally do NOT validate the `origin` field inside the + // clientDataJSON. The binding that matters is the authenticator's + // `rpIdHash` (checked below against the on-chain stored rpIdHash), + // which the authenticator hardware/OS computes from the registered + // relying party and refuses to sign cross-origin. + + // Validate "type" field is "webauthn.get" + let type_value = extract_top_level_string_field(raw_client_data_json, b"type")?; + if type_value != b"webauthn.get" { + return Err(AuthError::InvalidAuthenticationKind.into()); + } + + // Validate "challenge" field matches expected base64url(challenge_hash). + // L3: constant-time byte comparison. + let challenge_value = + extract_top_level_string_field(raw_client_data_json, b"challenge")?; + let expected_challenge_b64 = base64url_encode_no_pad(&hasher); + if !ct_eq(challenge_value, expected_challenge_b64.as_slice()) { + return Err(AuthError::InvalidMessageHash.into()); + } + + // Hash the raw clientDataJSON #[allow(unused_assignments)] let mut client_data_hash = [0u8; 32]; #[cfg(target_os = "solana")] unsafe { let _res = pinocchio::syscalls::sol_sha256( - [client_data_json.as_slice()].as_ptr() as *const u8, + [raw_client_data_json].as_ptr() as *const u8, 1, client_data_hash.as_mut_ptr(), ); } #[cfg(not(target_os = "solana"))] { - let _ = client_data_json; + let _ = raw_client_data_json; client_data_hash = [0u8; 32]; } + // --- Shared validation (both modes) --- + let auth_data_parser = AuthDataParser::new(authenticator_data_raw)?; if !auth_data_parser.is_user_present() { return Err(AuthError::PermissionDenied.into()); } - // Note: We intentionally do NOT check auth_data_parser.counter() (the WebAuthn hardware - // counter). Synced passkeys (iCloud Keychain, Google Password Manager) may return 0 or - // non-incrementing values. The program-controlled odometer above provides replay protection. + // Note: We intentionally do NOT check the WebAuthn hardware counter. + // Synced passkeys (iCloud, Google) may return 0 or non-incrementing values. - // Security Validation: - // Ensure the domain (rp_id_hash) the user provided in the instruction payload actually matches - // the rpIdHash that the authenticator (Hardware/FaceID) signed over inside authenticatorData. - if auth_data_parser.rp_id_hash() != computed_rp_id_hash { + // Validate rpIdHash in authenticatorData matches the stored rpIdHash. + if auth_data_parser.rp_id_hash() != stored_rp_id_hash { return Err(AuthError::InvalidPubkey.into()); } @@ -199,10 +217,10 @@ impl Authenticator for Secp256r1Authenticator { let instruction_pubkey_bytes = &auth_data[pubkey_offset..pubkey_offset + 33]; let expected_pubkey: &[u8; 33] = instruction_pubkey_bytes.try_into().unwrap(); - let mut signed_message = Vec::with_capacity(authenticator_data_raw.len() + 32); - signed_message.extend_from_slice(authenticator_data_raw); - signed_message.extend_from_slice(&client_data_hash); + // The precompile's signed message is authenticator_data ∥ client_data_hash. + // Pass the two slices separately to avoid an intermediate Vec allocation. + // Introspect the secp256r1 precompile instruction (must be the previous instruction) let sysvar_instructions = accounts .get(sysvar_ix_index) .ok_or(AuthError::InvalidAuthorityPayload)?; @@ -225,10 +243,11 @@ impl Authenticator for Secp256r1Authenticator { verify_secp256r1_instruction_data( secp_ix.get_instruction_data(), expected_pubkey, - &signed_message, + authenticator_data_raw, + &client_data_hash, )?; - // Signature verified successfully — now commit the counter update + // Signature verified successfully — commit the counter update header.counter = expected_counter; unsafe { std::ptr::write_unaligned( @@ -240,3 +259,52 @@ impl Authenticator for Secp256r1Authenticator { Ok(()) } } + +/// Constant-time byte slice equality. Returns `false` for different lengths; +/// otherwise XORs every byte pair into an accumulator and compares to zero, +/// ensuring the comparison takes the same time regardless of where (or if) the +/// bytes differ. Used for the Mode 1 challenge check. +#[inline(always)] +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut acc: u8 = 0; + for i in 0..a.len() { + acc |= a[i] ^ b[i]; + } + acc == 0 +} + +#[cfg(test)] +mod tests { + use super::ct_eq; + + #[test] + fn ct_eq_equal() { + assert!(ct_eq(b"abc", b"abc")); + assert!(ct_eq(b"", b"")); + assert!(ct_eq(&[0xFF; 43], &[0xFF; 43])); + } + + #[test] + fn ct_eq_different_length() { + assert!(!ct_eq(b"abc", b"abcd")); + assert!(!ct_eq(b"", b"a")); + } + + #[test] + fn ct_eq_differs_at_start() { + assert!(!ct_eq(b"xbc", b"abc")); + } + + #[test] + fn ct_eq_differs_at_end() { + assert!(!ct_eq(b"abx", b"abc")); + } + + #[test] + fn ct_eq_differs_at_middle() { + assert!(!ct_eq(b"axc", b"abc")); + } +} diff --git a/program/src/auth/secp256r1/webauthn.rs b/program/src/auth/secp256r1/webauthn.rs index f8d0371..db1725c 100644 --- a/program/src/auth/secp256r1/webauthn.rs +++ b/program/src/auth/secp256r1/webauthn.rs @@ -1,51 +1,9 @@ -#[allow(unused_imports)] use crate::error::AuthError; -#[allow(unused_imports)] use pinocchio::program_error::ProgramError; -/// Packed flags for clientDataJson reconstruction -#[derive(Clone, Copy, Debug)] -#[repr(C)] -pub struct ClientDataJsonReconstructionParams { - pub type_and_flags: u8, -} - -impl ClientDataJsonReconstructionParams { - #[allow(dead_code)] - const TYPE_CREATE: u8 = 0x00; - const TYPE_GET: u8 = 0x10; - const FLAG_CROSS_ORIGIN: u8 = 0x01; - const FLAG_HTTP_ORIGIN: u8 = 0x02; - const FLAG_GOOGLE_EXTRA: u8 = 0x04; - - pub fn auth_type(&self) -> AuthType { - if (self.type_and_flags & 0xF0) == Self::TYPE_GET { - AuthType::Get - } else { - AuthType::Create - } - } - - pub fn is_cross_origin(&self) -> bool { - self.type_and_flags & Self::FLAG_CROSS_ORIGIN != 0 - } - - pub fn is_http(&self) -> bool { - self.type_and_flags & Self::FLAG_HTTP_ORIGIN != 0 - } - - pub fn has_google_extra(&self) -> bool { - self.type_and_flags & Self::FLAG_GOOGLE_EXTRA != 0 - } -} - -#[derive(Clone, Copy, Debug)] -pub enum AuthType { - Create, - Get, -} - -/// Simple Base64URL encoder without padding +/// Simple Base64URL encoder without padding. +/// Used to compare an on-chain-computed challenge against the base64url value +/// the browser authenticator placed inside clientDataJSON. pub fn base64url_encode_no_pad(data: &[u8]) -> Vec { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; let mut result = Vec::with_capacity(data.len().div_ceil(3) * 4); @@ -70,48 +28,6 @@ pub fn base64url_encode_no_pad(data: &[u8]) -> Vec { result } -/// Reconstructs the clientDataJson -pub fn reconstruct_client_data_json( - params: &ClientDataJsonReconstructionParams, - rp_id: &[u8], - challenge: &[u8], -) -> Vec { - let challenge_b64url = base64url_encode_no_pad(challenge); - let type_str: &[u8] = match params.auth_type() { - AuthType::Create => b"webauthn.create", - AuthType::Get => b"webauthn.get", - }; - - let prefix: &[u8] = if params.is_http() { - b"http://" - } else { - b"https://" - }; - let cross_origin: &[u8] = if params.is_cross_origin() { - b"true" - } else { - b"false" - }; - - let mut json = Vec::with_capacity(256); - json.extend_from_slice(b"{\"type\":\""); - json.extend_from_slice(type_str); - json.extend_from_slice(b"\",\"challenge\":\""); - json.extend_from_slice(&challenge_b64url); - json.extend_from_slice(b"\",\"origin\":\""); - json.extend_from_slice(prefix); - json.extend_from_slice(rp_id); - json.extend_from_slice(b"\",\"crossOrigin\":"); - json.extend_from_slice(cross_origin); - - if params.has_google_extra() { - json.extend_from_slice(b",\"other_keys_can_be_added_here\":\"do not compare clientDataJSON against a template. See https://goo.gl/yabPex\""); - } - - json.extend_from_slice(b"}"); - json -} - /// Minimum authenticator data length: rpIdHash(32) + flags(1) + counter(4) = 37 pub const AUTH_DATA_MIN_LEN: usize = 37; @@ -144,3 +60,536 @@ impl<'a> AuthDataParser<'a> { u32::from_be_bytes(self.data[33..37].try_into().unwrap()) } } + +/// Extracts a top-level string value for a given key from a JSON object. +/// +/// Walks `{"key":"value", ...}` looking for the specified key at depth 1. +/// Returns the value bytes (without quotes). Rejects escaped strings +/// (backslash inside key or value) to prevent challenge injection. +pub fn extract_top_level_string_field<'a>( + json: &'a [u8], + field_name: &[u8], +) -> Result<&'a [u8], ProgramError> { + // Skip leading whitespace + let mut i = 0; + while i < json.len() && json[i].is_ascii_whitespace() { + i += 1; + } + if i >= json.len() || json[i] != b'{' { + return Err(AuthError::InvalidMessage.into()); + } + + let mut depth: usize = 0; + let mut cursor = i; + + while cursor < json.len() { + let byte = json[cursor]; + + if byte == b'{' { + depth += 1; + cursor += 1; + continue; + } + if byte == b'}' { + if depth == 0 { + return Err(AuthError::InvalidMessage.into()); + } + depth -= 1; + cursor += 1; + continue; + } + + // Only parse keys at the top level (depth == 1) + if depth == 1 && byte == b'"' { + // Parse key + let key_start = cursor + 1; + let mut key_end = key_start; + while key_end < json.len() { + let b = json[key_end]; + if b == b'"' { + break; + } + if b == b'\\' { + return Err(AuthError::InvalidMessage.into()); + } + key_end += 1; + } + if key_end >= json.len() { + return Err(AuthError::InvalidMessage.into()); + } + + // Skip past closing quote + cursor = key_end + 1; + + // Skip whitespace before colon + while cursor < json.len() && json[cursor].is_ascii_whitespace() { + cursor += 1; + } + if cursor >= json.len() || json[cursor] != b':' { + return Err(AuthError::InvalidMessage.into()); + } + cursor += 1; + + // Skip whitespace after colon + while cursor < json.len() && json[cursor].is_ascii_whitespace() { + cursor += 1; + } + if cursor >= json.len() { + return Err(AuthError::InvalidMessage.into()); + } + + // Check if this is the field we want + if &json[key_start..key_end] == field_name { + // Value must be a string + if json[cursor] != b'"' { + return Err(AuthError::InvalidMessage.into()); + } + let value_start = cursor + 1; + let mut value_end = value_start; + while value_end < json.len() { + let b = json[value_end]; + if b == b'"' { + return Ok(&json[value_start..value_end]); + } + if b == b'\\' { + return Err(AuthError::InvalidMessage.into()); + } + value_end += 1; + } + return Err(AuthError::InvalidMessage.into()); + } + + // Not our field — skip the value + if json[cursor] == b'"' { + // String value — skip to closing quote + cursor += 1; + while cursor < json.len() { + let b = json[cursor]; + if b == b'"' { + cursor += 1; + break; + } + if b == b'\\' { + return Err(AuthError::InvalidMessage.into()); + } + cursor += 1; + } + } else { + // Non-string value (number, bool, null, object, array) — skip + // until the next comma or closing brace at the same depth. + // Track nested braces and brackets, and when we encounter a + // string, consume it entirely so that `{`, `}`, `[`, `]`, or + // `,` inside the string body don't corrupt depth tracking. + // + // Without the inner string-skip, a payload like + // {"tokenBinding":{"id":"x}y"},"challenge":"real"} + // would have the `}` inside "x}y" mistakenly close the nested + // object and the parser would then mis-locate the top-level + // "challenge" entry. + let mut nest: usize = 0; + while cursor < json.len() { + match json[cursor] { + b'"' => { + // Consume the string; inner quotes/braces/commas + // must not affect outer nesting state. + cursor += 1; + while cursor < json.len() { + let b = json[cursor]; + if b == b'"' { + cursor += 1; + break; + } + if b == b'\\' { + return Err(AuthError::InvalidMessage.into()); + } + cursor += 1; + } + continue; + } + b'{' | b'[' => nest += 1, + b'}' | b']' => { + if nest == 0 { + break; + } + nest -= 1; + } + b',' if nest == 0 => break, + _ => {} + } + cursor += 1; + } + } + continue; + } + + cursor += 1; + } + + Err(AuthError::InvalidMessage.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── extract_top_level_string_field tests ─────────────────────────── + + #[test] + fn test_extract_field_basic() { + let json = br#"{"type":"webauthn.get","challenge":"abc123","origin":"https://example.com"}"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc123" + ); + assert_eq!( + extract_top_level_string_field(json, b"origin").unwrap(), + b"https://example.com" + ); + } + + #[test] + fn test_extract_field_with_bool_value() { + let json = + br#"{"type":"webauthn.get","challenge":"abc","crossOrigin":false,"origin":"https://x.com"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc" + ); + assert_eq!( + extract_top_level_string_field(json, b"origin").unwrap(), + b"https://x.com" + ); + } + + #[test] + fn test_extract_field_with_nested_object() { + // Real Android clientDataJSON has extra fields like androidPackageName + let json = + br#"{"type":"webauthn.get","challenge":"xyz","origin":"https://a.com","androidPackageName":"com.example.app"}"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"xyz" + ); + } + + #[test] + fn test_extract_field_with_nested_json_object() { + // Nested object should be skipped when looking for top-level keys + let json = br#"{"nested":{"challenge":"fake"},"type":"webauthn.get","challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + } + + #[test] + fn test_extract_field_missing_key() { + let json = br#"{"type":"webauthn.get"}"#; + assert!(extract_top_level_string_field(json, b"challenge").is_err()); + } + + #[test] + fn test_extract_field_rejects_escaped_key() { + // Backslash in key → reject (prevents injection) + let json = br#"{"ty\"pe":"webauthn.get"}"#; + assert!(extract_top_level_string_field(json, b"type").is_err()); + } + + #[test] + fn test_extract_field_rejects_escaped_value() { + // Backslash in value → reject + let json = br#"{"challenge":"abc\"def"}"#; + assert!(extract_top_level_string_field(json, b"challenge").is_err()); + } + + #[test] + fn test_extract_field_rejects_non_string_value() { + // challenge is a number, not a string → reject + let json = br#"{"challenge":12345}"#; + assert!(extract_top_level_string_field(json, b"challenge").is_err()); + } + + #[test] + fn test_extract_field_rejects_empty_input() { + assert!(extract_top_level_string_field(b"", b"type").is_err()); + } + + #[test] + fn test_extract_field_rejects_not_object() { + assert!(extract_top_level_string_field(b"[1,2,3]", b"type").is_err()); + } + + #[test] + fn test_extract_field_nested_challenge_not_found_at_top() { + // challenge only exists inside a nested object — should not be found + let json = br#"{"type":"webauthn.get","nested":{"challenge":"sneaky"}}"#; + assert!(extract_top_level_string_field(json, b"challenge").is_err()); + } + + #[test] + fn test_extract_field_with_whitespace() { + let json = br#"{ "type" : "webauthn.get" , "challenge" : "abc" }"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc" + ); + } + + #[test] + fn test_extract_field_google_extra() { + // Google Chrome adds this extra field + let json = br#"{"type":"webauthn.get","challenge":"abc","origin":"https://x.com","crossOrigin":false,"other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See https://goo.gl/yabPex"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc" + ); + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + } + + #[test] + fn test_extract_field_with_array_value() { + // Array value should be skipped properly + let json = br#"{"arr":[1,2,3],"challenge":"abc"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc" + ); + } + + #[test] + fn test_extract_field_with_nested_array_of_objects() { + let json = br#"{"arr":[{"challenge":"fake"},{"x":1}],"challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + // ─── M1 regression: string content inside nested value must not + // corrupt depth tracking ────────────────────────────────────── + + #[test] + fn test_extract_skips_string_containing_close_brace_in_nested_object() { + // Pre-fix, the `}` inside "x}y" would mistakenly close the nested + // object, and the parser would then mis-locate the top-level + // "challenge" entry. Post-fix, the inner string is consumed as a + // whole so nested depth stays at 1 until the real `}` at end of + // the tokenBinding value. + let json = br#"{"tokenBinding":{"id":"x}y"},"challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + #[test] + fn test_extract_skips_string_containing_close_bracket() { + let json = br#"{"arr":[{"id":"x]y"}],"challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + #[test] + fn test_extract_skips_string_containing_comma_in_nested_object() { + // Comma inside a string at non-zero depth — should not affect anything, + // but once we `continue` to the top of the skip loop we could be fooled + // into thinking the comma terminates the value. + let json = br#"{"obj":{"k":"a,b,c"},"challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + #[test] + fn test_extract_skips_string_containing_open_brace_in_array() { + let json = br#"{"arr":[{"id":"x{y"}],"challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + #[test] + fn test_extract_challenge_before_tokenbinding_still_works() { + // Safety: ensure the fix doesn't break the common happy path. + let json = br#"{"type":"webauthn.get","challenge":"real","tokenBinding":{"id":"x}y"}}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + #[test] + fn test_extract_rejects_backslash_in_skipped_string_inside_nested() { + // Backslashes are rejected in string values everywhere, including + // strings inside a skipped nested object. This prevents escape + // injection reaching the parser via nested fields. + let json = br#"{"obj":{"id":"a\"b"},"challenge":"real"}"#; + assert!(extract_top_level_string_field(json, b"challenge").is_err()); + } + + // ─── base64url_encode_no_pad tests ────────────────────────────────── + + #[test] + fn test_base64url_encode_empty() { + assert_eq!(base64url_encode_no_pad(&[]), b""); + } + + #[test] + fn test_base64url_encode_known_vectors() { + // "f" → "Zg" + assert_eq!(base64url_encode_no_pad(b"f"), b"Zg"); + // "fo" → "Zm8" + assert_eq!(base64url_encode_no_pad(b"fo"), b"Zm8"); + // "foo" → "Zm9v" + assert_eq!(base64url_encode_no_pad(b"foo"), b"Zm9v"); + } + + #[test] + fn test_base64url_encode_32_bytes() { + // SHA256 output (32 bytes) → 43 base64url chars (no padding) + let data = [0x11u8; 32]; + let encoded = base64url_encode_no_pad(&data); + assert_eq!(encoded.len(), 43); + // Verify no padding characters + assert!(!encoded.contains(&b'=')); + // Verify URL-safe: no + or / + assert!(!encoded.contains(&b'+')); + assert!(!encoded.contains(&b'/')); + } + + #[test] + fn test_base64url_uses_url_safe_chars() { + // 0xFB, 0xFF → should produce '-' and '_' instead of '+' and '/' + let data = [0xFB, 0xFF, 0xFE]; + let encoded = base64url_encode_no_pad(&data); + let encoded_str = std::str::from_utf8(&encoded).unwrap(); + assert!( + !encoded_str.contains('+') && !encoded_str.contains('/'), + "Must use URL-safe alphabet" + ); + } + + // ─── AuthDataParser tests ─────────────────────────────────────────── + + #[test] + fn test_auth_data_parser_basic() { + let mut data = [0u8; 37]; + // rpIdHash = first 32 bytes (zeros) + data[32] = 0x05; // flags: user present (0x01) + user verified (0x04) + data[33..37].copy_from_slice(&[0, 0, 0, 42]); // counter = 42 (big-endian) + + let parser = AuthDataParser::new(&data).unwrap(); + assert!(parser.is_user_present()); + assert!(parser.is_user_verified()); + assert_eq!(parser.counter(), 42); + assert_eq!(parser.rp_id_hash(), &[0u8; 32]); + } + + #[test] + fn test_auth_data_parser_no_flags() { + let data = [0u8; 37]; + let parser = AuthDataParser::new(&data).unwrap(); + assert!(!parser.is_user_present()); + assert!(!parser.is_user_verified()); + } + + #[test] + fn test_auth_data_parser_too_short() { + let data = [0u8; 36]; // Less than 37 + assert!(AuthDataParser::new(&data).is_err()); + } + + #[test] + fn test_auth_data_parser_with_extensions() { + // Real authenticators may return > 37 bytes (with extensions) + let mut data = [0u8; 100]; + data[32] = 0x41; // user present + attested credential data + let parser = AuthDataParser::new(&data).unwrap(); + assert!(parser.is_user_present()); + } + + // ─── Real-world clientDataJSON samples ────────────────────────────── + + #[test] + fn test_extract_from_chrome_sample() { + let json = br#"{"type":"webauthn.get","challenge":"dGVzdC1jaGFsbGVuZ2U","origin":"https://lazorkit.app","crossOrigin":false}"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"dGVzdC1jaGFsbGVuZ2U" + ); + assert_eq!( + extract_top_level_string_field(json, b"origin").unwrap(), + b"https://lazorkit.app" + ); + } + + #[test] + fn test_extract_from_android_sample() { + // Android may include androidPackageName and topOrigin + let json = br#"{"type":"webauthn.get","challenge":"abc123","origin":"https://example.com","androidPackageName":"com.example.app","topOrigin":"https://example.com"}"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc123" + ); + assert_eq!( + extract_top_level_string_field(json, b"androidPackageName").unwrap(), + b"com.example.app" + ); + } + + #[test] + fn test_extract_from_safari_no_crossorigin() { + // Safari may omit crossOrigin entirely + let json = + br#"{"type":"webauthn.get","challenge":"xyz","origin":"https://lazorkit.app"}"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"xyz" + ); + // crossOrigin field doesn't exist → error + assert!(extract_top_level_string_field(json, b"crossOrigin").is_err()); + } + + #[test] + fn test_extract_rejects_webauthn_create_type() { + let json = br#"{"type":"webauthn.create","challenge":"abc"}"#; + let type_val = extract_top_level_string_field(json, b"type").unwrap(); + assert_ne!(type_val, b"webauthn.get"); + assert_eq!(type_val, b"webauthn.create"); + } +} From 19b52f9d123dda12e8fa873302ada5b100fc1f2e Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 17:31:47 +0700 Subject: [PATCH 08/13] feat(processor): port wallet/authority/execute/session processors from upstream Byte-identical with lazorkit-protocol/program/src/processor/{wallet/create, authority/manage, authority/transfer_ownership, execute/deferred, session/revoke}.rs. Brings the on-chain authority data layout into alignment with upstream: Secp256r1 authority = header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145B Previously program-v2 stored variable-length raw rpId; the new layout stores a precomputed SHA256 digest at offset 113. Saves one sol_sha256 syscall per Execute. Critical for slot-share: existing wallets created on lazorkit-protocol must remain readable after binary swap. File names stay flat (program-v2 keeps `processor/create_wallet.rs` rather than upstream's `processor/wallet/create.rs`); content identical. --- program/src/processor/create_wallet.rs | 72 +++++++++++++++--- program/src/processor/execute_deferred.rs | 83 +++++++++++---------- program/src/processor/manage_authority.rs | 54 +++++++++++++- program/src/processor/revoke_session.rs | 5 ++ program/src/processor/transfer_ownership.rs | 74 ++++++++++++++---- 5 files changed, 217 insertions(+), 71 deletions(-) diff --git a/program/src/processor/create_wallet.rs b/program/src/processor/create_wallet.rs index e16b42e..1d70f5d 100644 --- a/program/src/processor/create_wallet.rs +++ b/program/src/processor/create_wallet.rs @@ -79,10 +79,13 @@ pub fn process( let (id_seed, full_auth_data) = match args.authority_type { 0 => { - if rest.len() != 32 { + // Use minimum-length check (consistent with AddAuthority / TransferOwnership). + // Exact-length check would reject clients that append trailing context bytes. + if rest.len() < 32 { return Err(ProgramError::InvalidInstructionData); } - (rest, rest) + let (pubkey, _) = rest.split_at(32); + (pubkey, pubkey) }, 1 => { // [credential_id_hash(32)] [pubkey(33)] [rpIdLen(1)] [rpId(N)] @@ -91,6 +94,12 @@ pub fn process( } let (credential_id_hash, rest_after_cred) = rest.split_at(32); let rp_id_len = rest_after_cred[33] as usize; + // Enforce a sane upper bound: max valid domain name is 253 chars. + // Without this an attacker-controlled payer could create a 369-byte + // authority account with 255 bytes of arbitrary rpId data. + if rp_id_len == 0 || rp_id_len > 253 { + return Err(ProgramError::InvalidInstructionData); + } let total_auth_data = 32 + 33 + 1 + rp_id_len; if rest.len() < total_auth_data { return Err(ProgramError::InvalidInstructionData); @@ -188,11 +197,18 @@ pub fn process( } // --- 2. Initialize Authority Account --- - // Authority accounts have a variable size depending on the authority type (e.g., Secp256r1 keys are larger). + // Fixed sizes per auth type: + // Ed25519 = header(48) + pubkey(32) = 80 bytes + // Secp256r1 = header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145 bytes + // + // For Secp256r1 we hash rpId once at creation and store the digest, so + // every subsequent Execute saves one sol_sha256 syscall. let header_size = std::mem::size_of::(); - let variable_size = full_auth_data.len(); - - let auth_space = header_size + variable_size; + let auth_space = match args.authority_type { + 0 => header_size + 32, // Ed25519 + 1 => header_size + 32 + 33 + 32, // Secp256r1 fixed + _ => return Err(AuthError::InvalidAuthenticationKind.into()), + }; let auth_rent = rent.minimum_balance(auth_space); // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) @@ -228,18 +244,50 @@ pub fn process( wallet: *wallet_pda.key(), }; - // safe write + // safe write of header let header_bytes = unsafe { std::slice::from_raw_parts( &header as *const AuthorityAccountHeader as *const u8, - std::mem::size_of::(), + header_size, ) }; - auth_account_data[0..std::mem::size_of::()] - .copy_from_slice(header_bytes); + auth_account_data[0..header_size].copy_from_slice(header_bytes); - let variable_target = &mut auth_account_data[header_size..]; - variable_target.copy_from_slice(full_auth_data); + // Write variable data + match args.authority_type { + 0 => { + // Ed25519: pubkey(32) — full_auth_data is exactly 32 bytes + auth_account_data[header_size..header_size + 32] + .copy_from_slice(&full_auth_data[..32]); + } + 1 => { + // Secp256r1: cred_hash(32) ∥ pubkey(33) ∥ rpIdHash(32). + // full_auth_data layout as parsed above: + // [cred_hash(32)] [pubkey(33)] [rpIdLen(1)] [rpId(N)] + auth_account_data[header_size..header_size + 32] + .copy_from_slice(&full_auth_data[..32]); + auth_account_data[header_size + 32..header_size + 32 + 33] + .copy_from_slice(&full_auth_data[32..32 + 33]); + // Compute rpIdHash from rpId + let rp_id_len = full_auth_data[32 + 33] as usize; + let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; + let rp_id_hash_offset = header_size + 32 + 33; + #[cfg(target_os = "solana")] + unsafe { + let _ = pinocchio::syscalls::sol_sha256( + [rp_id].as_ptr() as *const u8, + 1, + auth_account_data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { + let _ = rp_id; + auth_account_data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); + } + } + _ => unreachable!(), + } Ok(()) } diff --git a/program/src/processor/execute_deferred.rs b/program/src/processor/execute_deferred.rs index cc24c27..fafe0b4 100644 --- a/program/src/processor/execute_deferred.rs +++ b/program/src/processor/execute_deferred.rs @@ -1,5 +1,5 @@ use crate::{ - compact::parse_compact_instructions, + compact::{parse_compact_instructions_ref_with_len, CompactInstructionRef}, error::AuthError, state::{deferred::DeferredExecAccount, AccountDiscriminator}, }; @@ -99,14 +99,14 @@ pub fn process( return Err(AuthError::DeferredAuthorizationExpired.into()); } - // Parse compact instructions - let compact_instructions = parse_compact_instructions(instruction_data)?; + // Parse compact instructions and track consumed length. We hash the + // raw instruction_data[..consumed] directly — the parse/encode format + // is byte-identical, so there's no need to re-serialize. + let (compact_instructions, compact_len) = + parse_compact_instructions_ref_with_len(instruction_data)?; - // Serialize compact instructions to compute hash - let compact_bytes = crate::compact::serialize_compact_instructions(&compact_instructions); - - // Verify instructions hash - let instructions_hash = compute_sha256(&compact_bytes); + // Verify instructions hash against the exact bytes we parsed from + let instructions_hash = compute_sha256(&instruction_data[..compact_len]); if instructions_hash != deferred.instructions_hash { return Err(AuthError::DeferredHashMismatch.into()); } @@ -140,46 +140,47 @@ pub fn process( let close_data = unsafe { deferred_pda.borrow_mut_data_unchecked() }; close_data.fill(0); + // Reuse Vecs across inner CPI iterations — allocated once, cleared + + // repushed each iteration. Same optimisation as execute::immediate. + const MAX_INNER_ACCOUNTS: usize = 32; + let mut account_metas: Vec = Vec::with_capacity(MAX_INNER_ACCOUNTS); + let mut cpi_accounts: Vec = Vec::with_capacity(MAX_INNER_ACCOUNTS); + + let vault_bump_arr = [vault_bump]; + let seeds = [ + Seed::from(b"vault"), + Seed::from(wallet_pda.key().as_ref()), + Seed::from(&vault_bump_arr), + ]; + // Execute each compact instruction via CPI with vault PDA signing for compact_ix in &compact_instructions { let decompressed = compact_ix.decompress(accounts)?; - // Build AccountMeta array - let account_metas: Vec = decompressed - .accounts - .iter() - .map(|acc| AccountMeta { - pubkey: acc.key(), - is_signer: acc.is_signer() || acc.key() == vault_pda.key(), - is_writable: acc.is_writable(), - }) - .collect(); - // Prevent self-reentrancy if decompressed.program_id.as_ref() == program_id.as_ref() { return Err(AuthError::SelfReentrancyNotAllowed.into()); } + account_metas.clear(); + cpi_accounts.clear(); + for &acc in &decompressed.accounts { + account_metas.push(AccountMeta { + pubkey: acc.key(), + is_signer: acc.is_signer() || acc.key() == vault_pda.key(), + is_writable: acc.is_writable(), + }); + cpi_accounts.push(Account::from(acc)); + } + let ix = Instruction { program_id: decompressed.program_id, accounts: &account_metas, - data: &decompressed.data, + data: decompressed.data, }; - let vault_bump_arr = [vault_bump]; - let seeds = [ - Seed::from(b"vault"), - Seed::from(wallet_pda.key().as_ref()), - Seed::from(&vault_bump_arr), - ]; let signer: Signer = (&seeds).into(); - let cpi_accounts: Vec = decompressed - .accounts - .iter() - .map(|acc| Account::from(*acc)) - .collect(); - unsafe { invoke_signed_unchecked(&ix, &cpi_accounts, &[signer]); } @@ -209,26 +210,26 @@ fn compute_sha256(data: &[u8]) -> [u8; 32] { } /// Compute SHA256 hash of all account pubkeys referenced by compact instructions. -/// Same logic as execute.rs::compute_accounts_hash. +/// Matches execute::immediate::compute_accounts_hash. fn compute_accounts_hash( accounts: &[AccountInfo], - compact_instructions: &[crate::compact::CompactInstruction], + compact_instructions: &[CompactInstructionRef<'_>], ) -> Result<[u8; 32], ProgramError> { - let mut pubkeys_data = Vec::new(); + let mut refs: Vec<&[u8]> = Vec::with_capacity(compact_instructions.len() * 4); for ix in compact_instructions { let program_idx = ix.program_id_index as usize; if program_idx >= accounts.len() { return Err(ProgramError::InvalidInstructionData); } - pubkeys_data.extend_from_slice(accounts[program_idx].key().as_ref()); + refs.push(accounts[program_idx].key().as_ref()); - for &acc_idx in &ix.accounts { + for &acc_idx in ix.accounts { let idx = acc_idx as usize; if idx >= accounts.len() { return Err(ProgramError::InvalidInstructionData); } - pubkeys_data.extend_from_slice(accounts[idx].key().as_ref()); + refs.push(accounts[idx].key().as_ref()); } } @@ -237,15 +238,15 @@ fn compute_accounts_hash( #[cfg(target_os = "solana")] unsafe { pinocchio::syscalls::sol_sha256( - [pubkeys_data.as_slice()].as_ptr() as *const u8, - 1, + refs.as_ptr() as *const u8, + refs.len() as u64, hash.as_mut_ptr(), ); } #[cfg(not(target_os = "solana"))] { hash = [0xAA; 32]; - let _ = pubkeys_data; + let _ = refs; } Ok(hash) diff --git a/program/src/processor/manage_authority.rs b/program/src/processor/manage_authority.rs index 34d6073..1e165b7 100644 --- a/program/src/processor/manage_authority.rs +++ b/program/src/processor/manage_authority.rs @@ -91,6 +91,9 @@ pub fn process_add_authority( } let (credential_id_hash, rest_after_cred) = rest.split_at(32); let rp_id_len = rest_after_cred[33] as usize; + if rp_id_len == 0 || rp_id_len > 253 { + return Err(ProgramError::InvalidInstructionData); + } let total_auth_data = 32 + 33 + 1 + rp_id_len; if rest.len() < total_auth_data { return Err(ProgramError::InvalidInstructionData); @@ -198,6 +201,12 @@ pub fn process_add_authority( } // Authorization + // Validate new_role is a known value (0=Owner, 1=Admin, 2=Spender). + // Without this check an Owner could create a role-255 authority that can + // execute but cannot be revoked by any Admin. + if args.new_role > 2 { + return Err(AuthError::PermissionDenied.into()); + } if admin_header.role != 0 && (admin_header.role != 1 || args.new_role != 2) { return Err(AuthError::PermissionDenied.into()); } @@ -212,9 +221,13 @@ pub fn process_add_authority( } check_zero_data(new_auth_pda, ProgramError::AccountAlreadyInitialized)?; + // Fixed sizes per auth type (see wallet/create.rs for layout). let header_size = std::mem::size_of::(); - let variable_size = full_auth_data.len(); - let space = header_size + variable_size; + let space = match args.authority_type { + 0 => header_size + 32, // Ed25519: pubkey + 1 => header_size + 32 + 33 + 32, // Secp256r1: cred ∥ pubkey ∥ rpIdHash + _ => return Err(AuthError::InvalidAuthenticationKind.into()), + }; let rent_lamports = rent.minimum_balance(space); // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) @@ -252,8 +265,35 @@ pub fn process_add_authority( *(data.as_mut_ptr() as *mut AuthorityAccountHeader) = header; } - let variable_target = &mut data[header_size..]; - variable_target.copy_from_slice(full_auth_data); + // Write variable data. For Secp256r1 hash rpId once here so every Execute + // saves a sol_sha256 syscall. + match args.authority_type { + 0 => { + data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); + } + 1 => { + data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); + data[header_size + 32..header_size + 32 + 33] + .copy_from_slice(&full_auth_data[32..32 + 33]); + let rp_id_len = full_auth_data[32 + 33] as usize; + let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; + let rp_id_hash_offset = header_size + 32 + 33; + #[cfg(target_os = "solana")] + unsafe { + let _ = pinocchio::syscalls::sol_sha256( + [rp_id].as_ptr() as *const u8, + 1, + data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { + let _ = rp_id; + data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); + } + } + _ => unreachable!(), + } Ok(()) } @@ -398,6 +438,12 @@ pub fn process_remove_authority( } } + // Guard: if target == refund_dest the double-write would burn lamports and + // trigger a Solana lamport conservation error, aborting after doing work. + if target_auth_pda.key() == refund_dest.key() { + return Err(ProgramError::InvalidAccountData); + } + let target_lamports = unsafe { *target_auth_pda.borrow_mut_lamports_unchecked() }; let refund_lamports = unsafe { *refund_dest.borrow_mut_lamports_unchecked() }; unsafe { diff --git a/program/src/processor/revoke_session.rs b/program/src/processor/revoke_session.rs index dae1b39..aeada67 100644 --- a/program/src/processor/revoke_session.rs +++ b/program/src/processor/revoke_session.rs @@ -136,6 +136,11 @@ pub fn process( return Err(ProgramError::InvalidAccountData); } + // Guard: session_pda == refund_dest would burn lamports. + if session_pda.key() == refund_dest.key() { + return Err(ProgramError::InvalidAccountData); + } + // Close the session account — zero data and drain lamports session_data.fill(0); diff --git a/program/src/processor/transfer_ownership.rs b/program/src/processor/transfer_ownership.rs index 51dc8ad..0800f84 100644 --- a/program/src/processor/transfer_ownership.rs +++ b/program/src/processor/transfer_ownership.rs @@ -27,14 +27,16 @@ use crate::{ /// 2. **Authorization**: strictly enforced to only work if `current_owner` has `Role::Owner` (0). /// 3. **Atomic Swap**: /// - Creates the `new_owner` account. -/// - Closes the `current_owner` account and refunds rent to payer. +/// - Closes the `current_owner` account and refunds rent to `refund_dest`. /// /// # Accounts: /// 1. `[signer, writable]` Payer. /// 2. `[]` Wallet PDA. /// 3. `[signer, writable]` Current Owner Authority. /// 4. `[writable]` New Owner Authority. -/// 5. `[]` System Program. +/// 5. `[writable]` Refund Destination (receives closed current_owner rent). +/// 6. `[]` System Program. +/// 7. `[]` Rent Sysvar. /// /// Arguments for the `TransferOwnership` instruction. /// @@ -80,6 +82,9 @@ pub fn process( } let (hash, rest_after_hash) = rest.split_at(32); let rp_id_len = rest_after_hash[33] as usize; + if rp_id_len == 0 || rp_id_len > 253 { + return Err(ProgramError::InvalidInstructionData); + } let total_auth_data = 32 + 33 + 1 + rp_id_len; if rest.len() < total_auth_data { return Err(ProgramError::InvalidInstructionData); @@ -115,6 +120,9 @@ pub fn process( let new_owner = account_info_iter .next() .ok_or(ProgramError::NotEnoughAccountKeys)?; + let refund_dest = account_info_iter + .next() + .ok_or(ProgramError::NotEnoughAccountKeys)?; let system_program = account_info_iter .next() .ok_or(ProgramError::NotEnoughAccountKeys)?; @@ -126,6 +134,11 @@ pub fn process( if wallet_pda.owner() != program_id || current_owner.owner() != program_id { return Err(ProgramError::IllegalOwner); } + + // Guard: closing current_owner to itself would burn lamports. + if current_owner.key() == refund_dest.key() { + return Err(ProgramError::InvalidAccountData); + } // Validate Wallet Discriminator (Issue #7) let wallet_data = unsafe { wallet_pda.borrow_data_unchecked() }; if wallet_data.is_empty() || wallet_data[0] != AccountDiscriminator::Wallet as u8 { @@ -155,15 +168,16 @@ pub fn process( return Err(AuthError::PermissionDenied.into()); } - // Authenticate Current Owner - // Issue: Include payer + new_owner to prevent rent theft via payer swap - let mut ed25519_payload = Vec::with_capacity(64); + // Authenticate Current Owner. + // Sign over payer + new_owner + refund_dest to prevent substitution attacks. + let mut ed25519_payload = Vec::with_capacity(96); ed25519_payload.extend_from_slice(payer.key().as_ref()); ed25519_payload.extend_from_slice(new_owner.key().as_ref()); + ed25519_payload.extend_from_slice(refund_dest.key().as_ref()); match auth.authority_type { 0 => { - // Ed25519: Include payer + new_owner in signed payload + // Ed25519: sign over payer + new_owner + refund_dest Ed25519Authenticator.authenticate(accounts, data, &[], &ed25519_payload, &[3], program_id)?; }, 1 => { @@ -171,10 +185,11 @@ pub fn process( if !current_owner.is_writable() { return Err(ProgramError::InvalidAccountData); } - // Secp256r1: Include payer in signed payload to prevent rent theft - let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 32); + // Sign over data_payload + payer + refund_dest + let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 64); extended_data_payload.extend_from_slice(data_payload); extended_data_payload.extend_from_slice(payer.key().as_ref()); + extended_data_payload.extend_from_slice(refund_dest.key().as_ref()); Secp256r1Authenticator.authenticate( accounts, @@ -198,9 +213,13 @@ pub fn process( } check_zero_data(new_owner, ProgramError::AccountAlreadyInitialized)?; + // Fixed sizes per auth type (see wallet/create.rs for layout). let header_size = std::mem::size_of::(); - let variable_size = full_auth_data.len(); - let space = header_size + variable_size; + let space = match args.auth_type { + 0 => header_size + 32, // Ed25519: pubkey + 1 => header_size + 32 + 33 + 32, // Secp256r1: cred ∥ pubkey ∥ rpIdHash + _ => return Err(AuthError::InvalidAuthenticationKind.into()), + }; let rent = rent_obj.minimum_balance(space); // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) @@ -241,13 +260,40 @@ pub fn process( std::ptr::write_unaligned(data.as_mut_ptr() as *mut AuthorityAccountHeader, header); } - let variable_target = &mut data[header_size..]; - variable_target.copy_from_slice(full_auth_data); + // Write variable data. For Secp256r1 hash rpId once here so every Execute + // saves a sol_sha256 syscall. + match args.auth_type { + 0 => { + data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); + } + 1 => { + data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); + data[header_size + 32..header_size + 32 + 33] + .copy_from_slice(&full_auth_data[32..32 + 33]); + let rp_id_len = full_auth_data[32 + 33] as usize; + let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; + let rp_id_hash_offset = header_size + 32 + 33; + #[cfg(target_os = "solana")] + unsafe { + let _ = pinocchio::syscalls::sol_sha256( + [rp_id].as_ptr() as *const u8, + 1, + data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { + let _ = rp_id; + data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); + } + } + _ => unreachable!(), + } let current_lamports = unsafe { *current_owner.borrow_mut_lamports_unchecked() }; - let payer_lamports = unsafe { *payer.borrow_mut_lamports_unchecked() }; + let refund_lamports = unsafe { *refund_dest.borrow_mut_lamports_unchecked() }; unsafe { - *payer.borrow_mut_lamports_unchecked() = payer_lamports + *refund_dest.borrow_mut_lamports_unchecked() = refund_lamports .checked_add(current_lamports) .ok_or(ProgramError::ArithmeticOverflow)?; *current_owner.borrow_mut_lamports_unchecked() = 0; From 62ad92768a219d25c916bd919010f937ce17ebb6 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 17:35:04 +0700 Subject: [PATCH 09/13] fix(tests-sdk): include expiry_offset in deferred-execution signed payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Authorize instruction binds expiry_offset to the Secp256r1 signature hash via signed_payload = instructions_hash || accounts_hash || expiry_offset (u16 LE). Test code was building signed_payload without the expiryBuf, causing all 7 deferred tests to fail with InvalidMessageHash (3005). Add expiryBuf at all 6 sign sites; values match the corresponding createAuthorizeIx expiryOffset arg (4 × 300, 1 × 9000, 1 × 10). Verification: 65/65 vitest pass against live validator (was 58/65 before). --- tests-sdk/tests/08-deferred.test.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/tests-sdk/tests/08-deferred.test.ts b/tests-sdk/tests/08-deferred.test.ts index 777045c..f316190 100644 --- a/tests-sdk/tests/08-deferred.test.ts +++ b/tests-sdk/tests/08-deferred.test.ts @@ -116,8 +116,10 @@ describe('Deferred Execution', () => { ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - // Build signed_payload = instructions_hash || accounts_hash - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + // Build signed_payload = instructions_hash || accounts_hash || expiry_offset (u16 LE) + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(300); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); // Sign with Secp256r1 const { authPayload, precompileIx } = await signSecp256r1({ @@ -240,7 +242,9 @@ describe('Deferred Execution', () => { { pubkey: recipient3, isSigner: false, isWritable: true }, ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(300); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); // Counter is now 2 (after first test incremented to 1) const { authPayload, precompileIx } = await signSecp256r1({ @@ -380,7 +384,9 @@ describe('Deferred Execution', () => { { pubkey: recipient, isSigner: false, isWritable: true }, ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(300); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); const { authPayload, precompileIx } = await signSecp256r1({ key: ownerKey, @@ -504,7 +510,9 @@ describe('Deferred Execution', () => { { pubkey: recipient, isSigner: false, isWritable: true }, ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(300); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); const { authPayload, precompileIx } = await signSecp256r1({ key: ownerKey, @@ -610,7 +618,9 @@ describe('Deferred Execution', () => { { pubkey: recipient, isSigner: false, isWritable: true }, ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(9000); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); const { authPayload, precompileIx } = await signSecp256r1({ key: ownerKey, @@ -710,7 +720,9 @@ describe('Deferred Execution', () => { { pubkey: recipient, isSigner: false, isWritable: true }, ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(10); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); const { authPayload, precompileIx } = await signSecp256r1({ key: ownerKey, From cbf80cff736ee4a59de214362c7f915e5fff772c Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 17:36:35 +0700 Subject: [PATCH 10/13] chore(instruction): sync Shank IDL declarations from upstream (strip fee ix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit instruction.rs's ProgramIx enum declarations (account metadata: writable modifiers, positions, descriptions) had drifted from lazorkit-protocol. Runtime not affected — sdk-legacy uses hand-written instruction builders, not the generated IDL. Resync now to keep IDL output (program/idl.json) faithful to actual on-chain account expectations. Strip 5 protocol-mgmt instruction variants (disc 10-14): InitializeProtocol, UpdateProtocol, RegisterPayer, WithdrawTreasury, InitializeTreasuryShard. program-v2 keeps disc 0-9 only (matches entrypoint dispatch). Verification: - cargo build --features devnet → clean - bash scripts/check-no-fee.sh → clean --- program/idl.json | 48 ++++++++++++++----------- program/src/instruction.rs | 73 ++++++++++++++++---------------------- 2 files changed, 58 insertions(+), 63 deletions(-) diff --git a/program/idl.json b/program/idl.json index 5912e6d..73e28e8 100644 --- a/program/idl.json +++ b/program/idl.json @@ -92,10 +92,10 @@ "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true, "docs": [ - "Transaction payer" + "Payer and rent contributor" ] }, { @@ -108,10 +108,10 @@ }, { "name": "adminAuthority", - "isMut": false, - "isSigner": true, + "isMut": true, + "isSigner": false, "docs": [ - "Admin authority PDA authorizing this action" + "Admin authority PDA authorizing this action (counter incremented)" ] }, { @@ -186,7 +186,7 @@ "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true, "docs": [ "Transaction payer" @@ -202,10 +202,10 @@ }, { "name": "adminAuthority", - "isMut": false, - "isSigner": true, + "isMut": true, + "isSigner": false, "docs": [ - "Admin authority PDA authorizing this action" + "Admin authority PDA authorizing this action (counter incremented)" ] }, { @@ -245,10 +245,10 @@ "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true, "docs": [ - "Transaction payer" + "Payer and rent contributor" ] }, { @@ -275,6 +275,14 @@ "New owner authority PDA to be created" ] }, + { + "name": "refundDestination", + "isMut": true, + "isSigner": false, + "docs": [ + "Account to receive rent refund from closed current owner" + ] + }, { "name": "systemProgram", "isMut": false, @@ -335,7 +343,7 @@ "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true, "docs": [ "Transaction payer" @@ -351,18 +359,18 @@ }, { "name": "authority", - "isMut": false, + "isMut": true, "isSigner": false, "docs": [ - "Authority or Session PDA authorizing execution" + "Authority or Session PDA authorizing execution (counter incremented)" ] }, { "name": "vault", - "isMut": false, + "isMut": true, "isSigner": false, "docs": [ - "Vault PDA" + "Vault PDA (signer for CPI, lamports debited)" ] }, { @@ -407,10 +415,10 @@ }, { "name": "adminAuthority", - "isMut": false, - "isSigner": true, + "isMut": true, + "isSigner": false, "docs": [ - "Admin/Owner authority PDA authorizing logic" + "Admin/Owner authority PDA authorizing logic (counter incremented)" ] }, { @@ -650,7 +658,7 @@ "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true, "docs": [ "Transaction payer" diff --git a/program/src/instruction.rs b/program/src/instruction.rs index 21742cc..0c970f0 100644 --- a/program/src/instruction.rs +++ b/program/src/instruction.rs @@ -26,13 +26,13 @@ pub enum ProgramIx { }, /// Add a new authority to the wallet - #[account(0, signer, name = "payer", desc = "Transaction payer")] + #[account(0, signer, writable, name = "payer", desc = "Payer and rent contributor")] #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, - signer, + writable, name = "admin_authority", - desc = "Admin authority PDA authorizing this action" + desc = "Admin authority PDA authorizing this action (counter incremented)" )] #[account( 3, @@ -57,13 +57,13 @@ pub enum ProgramIx { }, /// Remove an authority from the wallet - #[account(0, signer, name = "payer", desc = "Transaction payer")] + #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, - signer, + writable, name = "admin_authority", - desc = "Admin authority PDA authorizing this action" + desc = "Admin authority PDA authorizing this action (counter incremented)" )] #[account( 3, @@ -86,7 +86,7 @@ pub enum ProgramIx { RemoveAuthority, /// Transfer ownership (atomic swap of Owner role) - #[account(0, signer, name = "payer", desc = "Transaction payer")] + #[account(0, signer, writable, name = "payer", desc = "Payer and rent contributor")] #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, @@ -100,10 +100,16 @@ pub enum ProgramIx { name = "new_owner_authority", desc = "New owner authority PDA to be created" )] - #[account(4, name = "system_program", desc = "System Program")] - #[account(5, name = "rent_sysvar", desc = "Rent Sysvar")] #[account( - 6, + 4, + writable, + name = "refund_destination", + desc = "Account to receive rent refund from closed current owner" + )] + #[account(5, name = "system_program", desc = "System Program")] + #[account(6, name = "rent_sysvar", desc = "Rent Sysvar")] + #[account( + 7, signer, optional, name = "authorizer_signer", @@ -116,14 +122,15 @@ pub enum ProgramIx { }, /// Execute transactions - #[account(0, signer, name = "payer", desc = "Transaction payer")] + #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, + writable, name = "authority", - desc = "Authority or Session PDA authorizing execution" + desc = "Authority or Session PDA authorizing execution (counter incremented)" )] - #[account(3, name = "vault", desc = "Vault PDA")] + #[account(3, writable, name = "vault", desc = "Vault PDA (signer for CPI, lamports debited)")] #[account( 4, optional, @@ -142,9 +149,9 @@ pub enum ProgramIx { #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, - signer, + writable, name = "admin_authority", - desc = "Admin/Owner authority PDA authorizing logic" + desc = "Admin/Owner authority PDA authorizing logic (counter incremented)" )] #[account(3, writable, name = "session", desc = "New session PDA to be created")] #[account(4, name = "system_program", desc = "System Program")] @@ -202,13 +209,7 @@ pub enum ProgramIx { /// /// Verifies compact instructions against stored hashes, executes via CPI /// with vault PDA signing, then closes the DeferredExec account. - #[account( - 0, - signer, - writable, - name = "payer", - desc = "Transaction payer" - )] + #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] #[account(1, name = "wallet", desc = "Wallet PDA")] #[account(2, writable, name = "vault", desc = "Vault PDA (signer for CPI)")] #[account( @@ -223,9 +224,7 @@ pub enum ProgramIx { name = "refund_destination", desc = "Account to receive rent refund from closed DeferredExec" )] - ExecuteDeferred { - instructions: Vec, - }, + ExecuteDeferred { instructions: Vec }, /// Reclaim an expired DeferredExec account and refund rent /// @@ -253,29 +252,15 @@ pub enum ProgramIx { /// Revoke a session key early (before expiry) /// /// Only Owner or Admin can revoke. Closes the session account and refunds rent. - #[account( - 0, - signer, - name = "payer", - desc = "Transaction payer" - )] - #[account( - 1, - name = "wallet", - desc = "Wallet PDA" - )] + #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] + #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, writable, name = "admin_authority", desc = "Owner/Admin authority PDA (counter incremented for Secp256r1)" )] - #[account( - 3, - writable, - name = "session", - desc = "Session PDA to revoke" - )] + #[account(3, writable, name = "session", desc = "Session PDA to revoke")] #[account( 4, writable, @@ -341,7 +326,9 @@ pub enum LazorKitInstruction { /// 2. `[]` Wallet PDA /// 3. `[writable]` Current Owner Authority PDA /// 4. `[writable]` New Owner Authority PDA - /// 5. `[]` System Program + /// 5. `[writable]` Refund Destination + /// 6. `[]` System Program + /// 7. `[]` Rent Sysvar TransferOwnership { new_type: u8, new_pubkey: [u8; 33], From 0554ae23308f3dbde9585b2b41bc36dfefd69fc4 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 17:43:20 +0700 Subject: [PATCH 11/13] docs(audit): delta brief + diff bundle for Accretion follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares the consolidated state at audit-pending-v1 for Accretion's delta-audit review. Local-only artifacts; not published, not pushed. Deliverables under docs/audit/: - DELTA_BRIEF.md: structured summary by phase (P0-P5), audit asks per phase, byte-identity claims vs upstream lazorkit-protocol, slot-share strategy context, contact + reproducibility info - program-src.diff: full unified diff of program/ between audit-baseline-2026-02-accretion (d1eaaeb, the prior audited state) and audit-pending-v1 (9c97fe2, the new state) - program-src.diff.stat: per-file changed-line summary - upstream-parity.txt: byte-identity report — 13/19 changed files byte-identical with already-audited lazorkit-protocol; 6 differ only for fee-strip / cosmetic reasons (URLs, layout) Local git tags created (not pushed): - audit-baseline-2026-02-accretion → d1eaaeb (prior audit baseline) - audit-pending-v1 → 9c97fe2 (current consolidated state) Audit ask is explicit per phase: - P1 action enforcement engine: confirm no new vulnerabilities - P5 auth/processor port: confirm Accretion's prior review of byte-identical upstream files extends to program-v2 - Slot-share compatibility: confirm state account layouts forward-compatible for binary swap at LazorjRF… mainnet slot Per user direction, NOT publishing or pushing yet — awaiting explicit permission for those operational steps. --- docs/audit/DELTA_BRIEF.md | 255 ++ docs/audit/program-src.diff | 5636 ++++++++++++++++++++++++++++++ docs/audit/program-src.diff.stat | 23 + docs/audit/upstream-parity.txt | 26 + 4 files changed, 5940 insertions(+) create mode 100644 docs/audit/DELTA_BRIEF.md create mode 100644 docs/audit/program-src.diff create mode 100644 docs/audit/program-src.diff.stat create mode 100644 docs/audit/upstream-parity.txt diff --git a/docs/audit/DELTA_BRIEF.md b/docs/audit/DELTA_BRIEF.md new file mode 100644 index 0000000..d568176 --- /dev/null +++ b/docs/audit/DELTA_BRIEF.md @@ -0,0 +1,255 @@ +# Accretion Audit Delta Brief — `program-v2` `audit-baseline-2026-02-accretion` → `audit-pending-v1` + +**Repository:** `lazor-kit/program-v2` +**Previous audit:** Accretion Labs, Solana Foundation, February 2026, A26SFR1 +([audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf](../../audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf)) +**Previous baseline tag:** `audit-baseline-2026-02-accretion` → commit `d1eaaeb` (Merge PR #49 fix/audit-hardening, 17/17 findings resolved) +**Delta tag:** `audit-pending-v1` → commit `9c97fe2` +**Delta scope:** 21 files in `program/`, +4168 / −444 lines + +This delta brief is for an **Accretion follow-up engagement** to confirm the +changes between the two tags introduce no new vulnerabilities versus the prior +audited state. Most of the new code is **byte-identical with the +already-audited `lazorkit-protocol` repo** (commercial sibling under the same +audit engagement); this is documented per-file in +[upstream-parity.txt](./upstream-parity.txt). + +--- + +## Strategic context (deploy plan) + +`program-v2` ships at the **same mainnet program ID** as `lazorkit-protocol` +(`LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi`). The foundation contract +period uses the `program-v2` (no-fee) build at that slot; afterwards, the +upgrade authority swaps the binary at the same slot to the `lazorkit-protocol` +(commercial, with-fee) build. + +**Implication for audit scope:** binary-swap compatibility requires that +program-v2 and lazorkit-protocol share identical: +- Account layouts (Wallet, Authority, Session, DeferredExec — verified + byte-identical via `state/*.rs` ↔ upstream) +- Instruction encoding (discriminators 0–9, account orderings — kept aligned + via the IDL sync in P5.4) +- Auth verification logic (auth/secp256r1/* — byte-identical with upstream + after P5.1) + +Audit confirmation of this delta therefore also benefits the slot-share +strategy: any wallet/authority/session created on either binary remains +verifiable by the other after swap. + +--- + +## Delta by phase + +The 23 commits between the two tags group into 5 phases: + +### P0 — Cherry-pick guardrails (zero-audit, mechanical) + +Tooling-only. Adds `scripts/strip-fee.sh`, `scripts/check-no-fee.sh`, +`scripts/fee-paths.txt`, and `.github/workflows/check-no-fee.yml` to enforce +that fee/admin/FeeRecord surface from `lazorkit-protocol` cannot leak into +`program-v2` during cherry-picks. **No `program/` source changed.** + +### P1 — Action types port + execute enforcement (delta-audit) + +**Most security-relevant section of the delta.** New on-chain feature. + +Files touched: +- `program/src/state/action.rs` (NEW, 697 lines) — byte-identical with + upstream `lazorkit-protocol/program/src/state/action.rs` (already audited). + Defines 8 action types + parser + validator. +- `program/src/state/session.rs` — minor (+23 lines) — adds + `SESSION_HEADER_SIZE` const and `actions_slice()` helper. Byte-identical + with upstream. +- `program/src/state/mod.rs` — adds `pub mod action;`. +- `program/src/processor/create_session.rs` — adopts variable-size session PDA + for optional actions buffer + validation at creation. Byte-identical with + upstream. +- `program/src/processor/execute_actions.rs` (NEW, 1644 lines) — + byte-identical with upstream `lazorkit-protocol/program/src/processor/execute/actions.rs`. + Pre-CPI program whitelist/blacklist + token snapshots; post-CPI delta + computation, SOL/token cap enforcement, recurring window resets, vault + invariant defenses against System::Assign / SetAuthority escapes. +- `program/src/processor/execute.rs` — wires pre/post action evaluation + around the CPI loop, adds the L5 anti-CPI guard for sessions, gross SOL + outflow tracking. Differs from upstream `processor/execute/immediate.rs` + by **1 import-path line** (`processor::execute_actions::` vs + `processor::execute::actions::`) due to flat vs nested processor layout. +- `program/src/error.rs` — adds 13 error variants (3020–3032) for action + validation, enforcement, and vault invariant defense. + +**Audit ask (P1):** Confirm action enforcement engine + execute integration +introduce no new vulnerabilities vs. the audited upstream version. + +### P2 — Dual-cluster + security_txt (zero-audit, mechanical) + +- `program/src/lib.rs` — embeds `security_txt!` with program-v2-specific URLs + (only difference vs upstream is the URL strings — see `upstream-parity.txt`) +- `assertions/src/lib.rs`, `assertions/Cargo.toml`, `program/Cargo.toml` — + Pattern D feature flags: `--features mainnet` embeds `LazorjRF…` (slot + shared with upstream); `--features devnet` embeds `FLb7…`; no feature → + `compile_error!` (prevents accidental cross-cluster deploy) + +No on-chain logic change. Build-time configuration only. + +### P3 — SDK consolidation (no audit) + +Off-chain only. `program-v2/sdk/solita-client/` deleted; `program-v2/tests-sdk/` +migrated to `@lazorkit/sdk-legacy` from npm (the same SDK +`lazorkit-protocol` ships). + +### P4 — Release infrastructure (no audit) + +`CHANGELOG.md`, `docs/MAINNET_DEPLOY_RUNBOOK.md`, `.github/workflows/release.yml`. +Documentation + CI only. + +### P5 — Consolidate: auth + processor port + IDL sync (delta-audit) + +**Aligns the remaining processor + auth files with upstream so the slot-share +strategy works end-to-end.** + +Files (all byte-identical with upstream after this phase, except where noted): +- `program/src/auth/secp256r1/{mod,webauthn,introspection}.rs` — port from + upstream. Replaces older typeAndFlags-format auth (which reconstructed + clientDataJSON server-side) with the format that embeds full raw + clientDataJSON in the auth payload. **Byte-identical with upstream.** +- `program/src/processor/{create_wallet,manage_authority,transfer_ownership, + execute_deferred,revoke_session}.rs` — port from upstream. Brings authority + data layout to: + `Secp256r1 authority = header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145B` + (previously variable-length raw rpId; now precomputed SHA256 digest at + offset 113, saves one syscall per Execute). **Byte-identical with upstream.** +- `program/src/instruction.rs` — Shank IDL declarations resynced from + upstream (account metadata: writable modifiers, positions, descriptions); + 5 fee instruction variants (disc 10–14) stripped. Runtime not affected + (sdk-legacy uses hand-written builders, not generated IDL). + +**Audit ask (P5):** Since these files are byte-identical with upstream, +confirm Accretion's prior review of the upstream files extends to this +program. Specifically: +- Auth payload format change (typeAndFlags → embedded clientDataJSON) — was + this reviewed in the upstream audit? Any concerns specific to program-v2 + context? +- Authority layout change (raw rpId → rpIdHash) — same question. + +--- + +## Files NOT changed since baseline (sanity) + +- `program/src/auth/ed25519.rs` — unchanged +- `program/src/auth/mod.rs` — unchanged +- `program/src/auth/traits.rs` — unchanged +- `program/src/utils.rs` — unchanged +- `program/src/state/wallet.rs` — unchanged (verified byte-identical with + upstream — wallet account layout is the cross-binary contract for + slot-share) +- `program/src/state/authority.rs` — unchanged (same reasoning as wallet) +- `program/src/state/deferred.rs` — unchanged +- `program/src/processor/authorize.rs` — unchanged +- `program/src/processor/reclaim_deferred.rs` — unchanged +- `program/src/entrypoint.rs` — unchanged dispatch (still discs 0–9) + +--- + +## Strip surface — what's NOT in `program-v2` + +Per the slot-share strategy, the following are intentionally absent: + +- `program/src/state/protocol_config.rs` — admin + fee config +- `program/src/state/integrator_record.rs` — FeeRecord +- `program/src/state/treasury_shard.rs` — treasury +- `program/src/processor/protocol/{initialize_protocol,update_protocol, + register_integrator,withdraw_treasury,initialize_treasury_shard}.rs` +- Discriminators 10–14 in entrypoint dispatch +- `try_collect_fee` function in entrypoint +- `ProtocolError` enum (codes 4001–4007) +- `ProtocolConfig` / `FeeRecord` / `TreasuryShard` discriminator slots (5/6/7) + in `state/mod.rs::AccountDiscriminator` + +`scripts/check-no-fee.sh` enforces this via `scripts/fee-paths.txt`. CI fails +on any introduction. Verified clean at `audit-pending-v1`. + +--- + +## What we want from this engagement + +Specific questions for Accretion: + +1. **P1 action enforcement** — is the `evaluate_pre_actions` / + `evaluate_post_actions` engine + integration into `execute.rs` and + `create_session.rs` introducing any new vulnerabilities the prior audit + didn't cover? Action discriminator collision, integer overflow in + recurring-limit window math, race conditions between snapshot/execute, + vault invariant gaps? + +2. **P5 auth port** — confirm the typeAndFlags → embedded-clientDataJSON + format change is safe in this context. Particular attention to: + - origin field validation (intentionally omitted — see [audit doc L1 + comment](../../program/src/auth/secp256r1/mod.rs)) + - challenge field base64url encoding/decoding round-trip + - rpIdHash computation timing + +3. **P5 authority layout change** — confirm the raw rpId → rpIdHash storage + change preserves all binding properties. Specifically that the on-chain + stored rpIdHash remains tied to the credential at registration. + +4. **Slot-share compatibility** — given program-v2 will live at + `LazorjRF…` mainnet slot and later be replaced in-place by + `lazorkit-protocol`'s commercial binary, confirm: + - State account layouts are forward-compatible (existing Wallet, + Authority, Session, DeferredExec accounts created by program-v2 are + readable + valid for the commercial binary) + - Auth verification continues working for sessions/wallets created + pre-swap + +5. **Anything Accretion flagged in the prior audit that may have regressed** + — full diff bundle at [program-src.diff](./program-src.diff) for + line-by-line review. + +--- + +## Deliverables included + +- [DELTA_BRIEF.md](./DELTA_BRIEF.md) (this file) +- [program-src.diff](./program-src.diff) — full unified diff of `program/` + between the two tags (~5600 lines) +- [program-src.diff.stat](./program-src.diff.stat) — per-file changed-line + counts +- [upstream-parity.txt](./upstream-parity.txt) — byte-identity report vs + `lazorkit-protocol` +- Git tags `audit-baseline-2026-02-accretion` (commit `d1eaaeb`) and + `audit-pending-v1` (commit `9c97fe2`) +- This brief is intended to be sent alongside the on-chain `security_txt!` + pointer to the prior PDF. + +## Out of scope for this brief + +- `tests-sdk/`, `sdk/`, `docs/`, `scripts/`, `.github/`, `Cargo.toml`, + `assertions/Cargo.toml` — non-program changes (test infrastructure, build + config). Available in the full `git diff audit-baseline-2026-02-accretion..audit-pending-v1` + if Accretion requests but not part of the on-chain audit ask. +- Operational items (mainnet deploy, multisig setup, binary swap procedure) + — handled in [docs/MAINNET_DEPLOY_RUNBOOK.md](../MAINNET_DEPLOY_RUNBOOK.md). + +--- + +## Build reproducibility + +```bash +git checkout audit-pending-v1 +cd program && cargo build-sbf --features mainnet +shasum -a 256 ../target/deploy/lazorkit_program.so +# Hash should match the artifact in the eventual mainnet deploy. +``` + +CI workflow at `.github/workflows/release.yml` reproduces and publishes the +hash on each tag push. + +--- + +## Contact + +- Email: security@lazorkit.app +- GitHub Security Advisories: https://github.com/lazor-kit/program-v2/security/advisories/new +- On-chain pointer: `solana program show LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi` + → `security_txt!` block links to this repo + audit PDF. diff --git a/docs/audit/program-src.diff b/docs/audit/program-src.diff new file mode 100644 index 0000000..4eef8f5 --- /dev/null +++ b/docs/audit/program-src.diff @@ -0,0 +1,5636 @@ +diff --git a/program/Cargo.toml b/program/Cargo.toml +index 37229b6..a9b9e4b 100644 +--- a/program/Cargo.toml ++++ b/program/Cargo.toml +@@ -6,6 +6,14 @@ edition = "2021" + [lib] + crate-type = ["cdylib", "lib"] + ++# Cluster selection — forwards to the assertions crate where the program ID ++# is embedded via declare_id!. Exactly one must be set at build time: ++# cargo build-sbf --features mainnet ++# cargo build-sbf --features devnet ++[features] ++mainnet = ["assertions/mainnet"] ++devnet = ["assertions/devnet"] ++ + [dependencies] + pinocchio = { workspace = true } + pinocchio-pubkey = { workspace = true } +@@ -13,6 +21,8 @@ pinocchio-system = { workspace = true } + no-padding = { workspace = true } + assertions = { workspace = true } + shank = { version = "0.4.2", git = "https://github.com/anagrambuild/shank.git" } ++solana-security-txt = "1.1.2" ++default-env = "0.1" + + [dev-dependencies] + solana-sdk = "2.1" +diff --git a/program/idl.json b/program/idl.json +index 5912e6d..73e28e8 100644 +--- a/program/idl.json ++++ b/program/idl.json +@@ -92,10 +92,10 @@ + "accounts": [ + { + "name": "payer", +- "isMut": false, ++ "isMut": true, + "isSigner": true, + "docs": [ +- "Transaction payer" ++ "Payer and rent contributor" + ] + }, + { +@@ -108,10 +108,10 @@ + }, + { + "name": "adminAuthority", +- "isMut": false, +- "isSigner": true, ++ "isMut": true, ++ "isSigner": false, + "docs": [ +- "Admin authority PDA authorizing this action" ++ "Admin authority PDA authorizing this action (counter incremented)" + ] + }, + { +@@ -186,7 +186,7 @@ + "accounts": [ + { + "name": "payer", +- "isMut": false, ++ "isMut": true, + "isSigner": true, + "docs": [ + "Transaction payer" +@@ -202,10 +202,10 @@ + }, + { + "name": "adminAuthority", +- "isMut": false, +- "isSigner": true, ++ "isMut": true, ++ "isSigner": false, + "docs": [ +- "Admin authority PDA authorizing this action" ++ "Admin authority PDA authorizing this action (counter incremented)" + ] + }, + { +@@ -245,10 +245,10 @@ + "accounts": [ + { + "name": "payer", +- "isMut": false, ++ "isMut": true, + "isSigner": true, + "docs": [ +- "Transaction payer" ++ "Payer and rent contributor" + ] + }, + { +@@ -275,6 +275,14 @@ + "New owner authority PDA to be created" + ] + }, ++ { ++ "name": "refundDestination", ++ "isMut": true, ++ "isSigner": false, ++ "docs": [ ++ "Account to receive rent refund from closed current owner" ++ ] ++ }, + { + "name": "systemProgram", + "isMut": false, +@@ -335,7 +343,7 @@ + "accounts": [ + { + "name": "payer", +- "isMut": false, ++ "isMut": true, + "isSigner": true, + "docs": [ + "Transaction payer" +@@ -351,18 +359,18 @@ + }, + { + "name": "authority", +- "isMut": false, ++ "isMut": true, + "isSigner": false, + "docs": [ +- "Authority or Session PDA authorizing execution" ++ "Authority or Session PDA authorizing execution (counter incremented)" + ] + }, + { + "name": "vault", +- "isMut": false, ++ "isMut": true, + "isSigner": false, + "docs": [ +- "Vault PDA" ++ "Vault PDA (signer for CPI, lamports debited)" + ] + }, + { +@@ -407,10 +415,10 @@ + }, + { + "name": "adminAuthority", +- "isMut": false, +- "isSigner": true, ++ "isMut": true, ++ "isSigner": false, + "docs": [ +- "Admin/Owner authority PDA authorizing logic" ++ "Admin/Owner authority PDA authorizing logic (counter incremented)" + ] + }, + { +@@ -650,7 +658,7 @@ + "accounts": [ + { + "name": "payer", +- "isMut": false, ++ "isMut": true, + "isSigner": true, + "docs": [ + "Transaction payer" +diff --git a/program/src/auth/secp256r1/introspection.rs b/program/src/auth/secp256r1/introspection.rs +index a8ae963..ea95526 100644 +--- a/program/src/auth/secp256r1/introspection.rs ++++ b/program/src/auth/secp256r1/introspection.rs +@@ -61,12 +61,18 @@ impl Secp256r1SignatureOffsets { + } + + /// Verify the secp256r1 instruction data contains the expected signature and +-/// public key. This also validates that the secp256r1 precompile offsets point +-/// to the expected locations, ensuring proper data alignment. ++/// public key. Also validates that the secp256r1 precompile offsets point to ++/// the expected locations, ensuring proper data alignment. ++/// ++/// The expected precompile message is passed as TWO slices — the ++/// authenticator_data and the clientDataJSON hash — which are concatenated ++/// by the on-chain secp256r1 precompile as its signed message. Accepting ++/// two slices here lets the caller skip a Vec allocation for the concat. + pub fn verify_secp256r1_instruction_data( + instruction_data: &[u8], + expected_pubkey: &[u8; 33], +- expected_message: &[u8], ++ auth_data: &[u8], ++ client_data_hash: &[u8; 32], + ) -> Result<(), ProgramError> { + // Minimum check: must have at least the header and offsets + if instruction_data.len() < DATA_START { +@@ -111,25 +117,257 @@ pub fn verify_secp256r1_instruction_data( + if offsets.message_data_offset as usize != MESSAGE_DATA_OFFSET { + return Err(AuthError::InvalidInstruction.into()); + } +- if offsets.message_data_size as usize != expected_message.len() { ++ let expected_msg_len = auth_data.len() + client_data_hash.len(); ++ if offsets.message_data_size as usize != expected_msg_len { + return Err(AuthError::InvalidInstruction.into()); + } + + // Dynamic length check: instruction must contain the full message +- if instruction_data.len() < MESSAGE_DATA_OFFSET + expected_message.len() { ++ if instruction_data.len() < MESSAGE_DATA_OFFSET + expected_msg_len { + return Err(AuthError::InvalidInstruction.into()); + } + + let pubkey_data = &instruction_data + [PUBKEY_DATA_OFFSET..PUBKEY_DATA_OFFSET + COMPRESSED_PUBKEY_SERIALIZED_SIZE]; +- let message_data = +- &instruction_data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + expected_message.len()]; +- + if pubkey_data != expected_pubkey { + return Err(AuthError::InvalidPubkey.into()); + } +- if message_data != expected_message { ++ ++ // Compare the precompile's message area against the two caller-supplied ++ // slices piecewise — no concat, no allocation. ++ let msg_auth = &instruction_data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + auth_data.len()]; ++ if msg_auth != auth_data { ++ return Err(AuthError::InvalidMessageHash.into()); ++ } ++ let hash_start = MESSAGE_DATA_OFFSET + auth_data.len(); ++ let msg_hash = &instruction_data[hash_start..hash_start + client_data_hash.len()]; ++ if msg_hash != client_data_hash { + return Err(AuthError::InvalidMessageHash.into()); + } + Ok(()) + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ /// Helper: build valid secp256r1 precompile instruction data with the standard layout. ++ fn build_precompile_ix_data( ++ pubkey: &[u8; 33], ++ signature: &[u8; 64], ++ message: &[u8], ++ ) -> Vec { ++ let total_len = DATA_START + 64 + 33 + 1 + message.len(); ++ let mut data = vec![0u8; total_len]; ++ ++ // Header ++ data[0] = 1; // num_signatures ++ data[1] = 0; // padding ++ ++ // Offsets (little-endian) ++ data[2..4].copy_from_slice(&(SIGNATURE_DATA_OFFSET as u16).to_le_bytes()); ++ data[4..6].copy_from_slice(&0xFFFFu16.to_le_bytes()); // sig ix index ++ data[6..8].copy_from_slice(&(PUBKEY_DATA_OFFSET as u16).to_le_bytes()); ++ data[8..10].copy_from_slice(&0xFFFFu16.to_le_bytes()); // pubkey ix index ++ data[10..12].copy_from_slice(&(MESSAGE_DATA_OFFSET as u16).to_le_bytes()); ++ data[12..14].copy_from_slice(&(message.len() as u16).to_le_bytes()); // msg size ++ data[14..16].copy_from_slice(&0xFFFFu16.to_le_bytes()); // msg ix index ++ ++ // Data ++ data[SIGNATURE_DATA_OFFSET..SIGNATURE_DATA_OFFSET + 64].copy_from_slice(signature); ++ data[PUBKEY_DATA_OFFSET..PUBKEY_DATA_OFFSET + 33].copy_from_slice(pubkey); ++ // Byte at offset 113 is alignment padding (zero) ++ data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + message.len()] ++ .copy_from_slice(message); ++ ++ data ++ } ++ ++ #[test] ++ fn test_verify_valid_instruction_data() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_ok()); ++ } ++ ++ #[test] ++ fn test_verify_variable_length_message() { ++ // Mode 1 messages are authenticatorData(37+) + clientDataJsonHash(32) = 69+ bytes. ++ // We split into the two halves exactly like the caller does post-refactor. ++ let pubkey = [0x03; 33]; ++ let signature = [0xCD; 64]; ++ let message = [0x22; 69]; ++ ++ let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ let auth_data: &[u8] = &message[..37]; ++ let client_data_hash: &[u8; 32] = &message[37..].try_into().unwrap(); ++ assert!( ++ verify_secp256r1_instruction_data(&ix_data, &pubkey, auth_data, client_data_hash) ++ .is_ok() ++ ); ++ } ++ ++ #[test] ++ fn test_verify_rejects_wrong_pubkey() { ++ let pubkey = [0x02; 33]; ++ let wrong_pubkey = [0x03; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ let err = ++ verify_secp256r1_instruction_data(&ix_data, &wrong_pubkey, &[], &message).unwrap_err(); ++ assert_eq!(err, AuthError::InvalidPubkey.into()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_wrong_message() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ let wrong_message = [0x22; 32]; ++ ++ let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ let err = ++ verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &wrong_message).unwrap_err(); ++ assert_eq!(err, AuthError::InvalidMessageHash.into()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_zero_signatures() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ ix_data[0] = 0; // zero signatures ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_multiple_signatures() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ ix_data[0] = 2; // two signatures ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_cross_instruction_sig_index() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Set signature_instruction_index to 0 instead of 0xFFFF ++ ix_data[4..6].copy_from_slice(&0u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_cross_instruction_pubkey_index() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Set public_key_instruction_index to 1 instead of 0xFFFF ++ ix_data[8..10].copy_from_slice(&1u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_cross_instruction_msg_index() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Set message_instruction_index to 0 instead of 0xFFFF ++ ix_data[14..16].copy_from_slice(&0u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_wrong_pubkey_offset() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Tamper pubkey_offset to point elsewhere ++ ix_data[6..8].copy_from_slice(&200u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_wrong_message_offset() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Tamper message_data_offset ++ ix_data[10..12].copy_from_slice(&50u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_wrong_signature_offset() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Tamper signature_offset ++ ix_data[2..4].copy_from_slice(&100u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_message_size_mismatch() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Set message_data_size to wrong value ++ ix_data[12..14].copy_from_slice(&64u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_too_short_data() { ++ let pubkey = [0x02; 33]; ++ let message = [0x11; 32]; ++ ++ // Only 2 bytes — way too short ++ assert!(verify_secp256r1_instruction_data(&[0x01, 0x00], &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_truncated_message_area() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Truncate — remove last 10 bytes so message area is incomplete ++ ix_data.truncate(ix_data.len() - 10); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_offsets_constants_are_consistent() { ++ assert_eq!(DATA_START, 16); // 2 header + 14 offsets ++ assert_eq!(SIGNATURE_DATA_OFFSET, 16); ++ assert_eq!(PUBKEY_DATA_OFFSET, 16 + 64); // 80 ++ assert_eq!(MESSAGE_DATA_OFFSET, 80 + 33 + 1); // 114 ++ } ++} +diff --git a/program/src/auth/secp256r1/mod.rs b/program/src/auth/secp256r1/mod.rs +index a6bafe8..ecc8363 100644 +--- a/program/src/auth/secp256r1/mod.rs ++++ b/program/src/auth/secp256r1/mod.rs +@@ -15,9 +15,7 @@ pub mod introspection; + pub mod webauthn; + + use self::introspection::verify_secp256r1_instruction_data; +-use self::webauthn::{ +- reconstruct_client_data_json, AuthDataParser, ClientDataJsonReconstructionParams, +-}; ++use self::webauthn::{base64url_encode_no_pad, extract_top_level_string_field, AuthDataParser}; + + use crate::auth::traits::Authenticator; + use crate::utils::get_stack_height; +@@ -31,11 +29,20 @@ pub struct Secp256r1Authenticator; + impl Authenticator for Secp256r1Authenticator { + /// Authenticates a Secp256r1 signature (WebAuthn/Passkeys). + /// +- /// Auth payload layout: +- /// [slot(8)] [counter(4)] [sysvarIxIdx(1)] [flags(1)] [authenticatorData(M)] ++ /// Auth payload layout (raw clientDataJSON — the only supported mode): ++ /// [slot(8)] [counter(4)] [sysvarIxIdx(1)] [_reserved(1)] ++ /// [authDataLen(2 LE)] [authenticatorData(M)] ++ /// [cdjLen(2 LE)] [clientDataJson(N)] ++ /// ++ /// rpIdHash is pre-computed at authority creation and stored on the ++ /// Authority account, so every Execute saves one sol_sha256 syscall and ++ /// the Authority account size is fixed (145 bytes for Secp256r1). + /// +- /// rpId is stored on the authority account (not in the payload). +- /// Counter is a program-controlled u32 odometer. Client must submit `on_chain_counter + 1`. ++ /// Counter is a program-controlled u32 odometer. Client must submit ++ /// `on_chain_counter + 1`. ++ /// ++ /// Programmatic/bot signing should use Ed25519 authorities instead — ++ /// Secp256r1 is passkeys-only. + fn authenticate( + &self, + accounts: &[AccountInfo], +@@ -45,26 +52,23 @@ impl Authenticator for Secp256r1Authenticator { + discriminator: &[u8], + program_id: &Pubkey, + ) -> Result<(), ProgramError> { +- // Minimum: slot(8) + counter(4) + sysvarIxIdx(1) + flags(1) = 14 +- if auth_payload.len() < 14 { ++ // Minimum: slot(8) + counter(4) + sysvarIxIdx(1) + reserved(1) = 14, ++ // plus authDataLen(2) + cdjLen(2) = 18 before any payload bytes. ++ if auth_payload.len() < 18 { + return Err(AuthError::InvalidAuthorityPayload.into()); + } + + let slot = u64::from_le_bytes(auth_payload[0..8].try_into().unwrap()); + let submitted_counter = u32::from_le_bytes(auth_payload[8..12].try_into().unwrap()); + let sysvar_ix_index = auth_payload[12] as usize; +- +- let reconstruction_params = ClientDataJsonReconstructionParams { +- type_and_flags: auth_payload[13], +- }; +- let authenticator_data_raw: &[u8] = &auth_payload[14..]; ++ // auth_payload[13] reserved (carried over from legacy mode byte). + + // Anti-CPI check: prevent cross-program authentication attacks + if get_stack_height() > 1 { + return Err(AuthError::PermissionDenied.into()); + } + +- // Validate slot freshness using Clock sysvar (replaces SlotHashes lookup) ++ // Validate slot freshness using Clock sysvar + let clock = Clock::get()?; + let current_slot = clock.slot; + if slot > current_slot { +@@ -84,55 +88,33 @@ impl Authenticator for Secp256r1Authenticator { + }; + + // --- Odometer validation --- +- // The client must submit exactly `stored_counter + 1`. +- // This decouples replay protection from the WebAuthn hardware counter, +- // which is unreliable for synced passkeys (iCloud, Google). + let expected_counter = header.counter.wrapping_add(1); + if submitted_counter != expected_counter { + return Err(AuthError::SignatureReused.into()); + } + +- // Secp256r1 on-chain data layout: +- // [Header(48)] [credential_id_hash(32)] [Pubkey(33)] [rpIdLen(1)] [rpId(N)] +- let pubkey_offset = header_size + 32; // skip credential_id_hash +- if auth_data.len() < pubkey_offset + 33 { ++ // Secp256r1 on-chain data layout (fixed 145 bytes total): ++ // [Header(48)] [credential_id_hash(32)] [Pubkey(33)] [rpIdHash(32)] ++ let pubkey_offset = header_size + 32; // 80 ++ let rp_id_hash_offset = pubkey_offset + 33; // 113 ++ if auth_data.len() < rp_id_hash_offset + 32 { + return Err(AuthError::InvalidAuthorityPayload.into()); + } +- +- // Read rpId from authority account data (stored at creation time) +- let rp_id_len_offset = pubkey_offset + 33; +- if auth_data.len() < rp_id_len_offset + 1 { +- return Err(AuthError::InvalidAuthorityPayload.into()); +- } +- let rp_id_len = auth_data[rp_id_len_offset] as usize; +- let rp_id_offset = rp_id_len_offset + 1; +- if auth_data.len() < rp_id_offset + rp_id_len { +- return Err(AuthError::InvalidAuthorityPayload.into()); +- } +- let rp_id = &auth_data[rp_id_offset..rp_id_offset + rp_id_len]; +- +- #[allow(unused_assignments)] +- let mut computed_rp_id_hash = [0u8; 32]; +- #[cfg(target_os = "solana")] +- unsafe { +- let _res = pinocchio::syscalls::sol_sha256( +- [rp_id].as_ptr() as *const u8, +- 1, +- computed_rp_id_hash.as_mut_ptr(), +- ); +- } +- #[cfg(not(target_os = "solana"))] +- { +- computed_rp_id_hash = [0u8; 32]; +- } ++ let stored_rp_id_hash = &auth_data[rp_id_hash_offset..rp_id_hash_offset + 32]; + + let payer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + if !payer.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + +- // Build challenge hash: +- // SHA256(discriminator || auth_payload || signed_payload || slot || payer || counter || program_id) ++ // Challenge hash: ++ // SHA256(discriminator || auth_payload[..14] || signed_payload ++ // || payer || counter || program_id) ++ // ++ // Only the 14-byte fixed prefix of auth_payload is included because the ++ // remainder contains clientDataJSON — which is produced by the ++ // authenticator *after* signing the challenge, so it can't be in the ++ // hash input. + let counter_bytes = expected_counter.to_le_bytes(); + #[allow(unused_assignments)] + let mut hasher = [0u8; 32]; +@@ -141,57 +123,93 @@ impl Authenticator for Secp256r1Authenticator { + let _res = pinocchio::syscalls::sol_sha256( + [ + discriminator, +- auth_payload, ++ &auth_payload[..14], + signed_payload, +- &slot.to_le_bytes(), + payer.key().as_ref(), + &counter_bytes, + program_id.as_ref(), + ] + .as_ptr() as *const u8, +- 7, ++ 6, + hasher.as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { +- let _ = signed_payload; +- let _ = discriminator; +- let _ = counter_bytes; +- let _ = program_id; ++ let _ = (signed_payload, discriminator, counter_bytes, program_id); + hasher = [0u8; 32]; + } + +- let client_data_json = reconstruct_client_data_json(&reconstruction_params, rp_id, &hasher); ++ // --- Parse Mode 1 payload: authenticatorData + clientDataJSON --- ++ let auth_data_len = ++ u16::from_le_bytes(auth_payload[14..16].try_into().unwrap()) as usize; ++ if auth_payload.len() < 16 + auth_data_len + 2 { ++ return Err(AuthError::InvalidAuthorityPayload.into()); ++ } ++ let authenticator_data_raw = &auth_payload[16..16 + auth_data_len]; ++ ++ let cdj_len_offset = 16 + auth_data_len; ++ let cdj_len = ++ u16::from_le_bytes(auth_payload[cdj_len_offset..cdj_len_offset + 2].try_into().unwrap()) ++ as usize; ++ let cdj_offset = cdj_len_offset + 2; ++ // L2: strict length — trailing bytes after cdj are not covered by ++ // challenge hash or precompile message, so they're rejected. ++ if cdj_len == 0 || auth_payload.len() != cdj_offset + cdj_len { ++ return Err(AuthError::InvalidAuthorityPayload.into()); ++ } ++ let raw_client_data_json = &auth_payload[cdj_offset..cdj_offset + cdj_len]; ++ ++ // L1: We intentionally do NOT validate the `origin` field inside the ++ // clientDataJSON. The binding that matters is the authenticator's ++ // `rpIdHash` (checked below against the on-chain stored rpIdHash), ++ // which the authenticator hardware/OS computes from the registered ++ // relying party and refuses to sign cross-origin. ++ ++ // Validate "type" field is "webauthn.get" ++ let type_value = extract_top_level_string_field(raw_client_data_json, b"type")?; ++ if type_value != b"webauthn.get" { ++ return Err(AuthError::InvalidAuthenticationKind.into()); ++ } ++ ++ // Validate "challenge" field matches expected base64url(challenge_hash). ++ // L3: constant-time byte comparison. ++ let challenge_value = ++ extract_top_level_string_field(raw_client_data_json, b"challenge")?; ++ let expected_challenge_b64 = base64url_encode_no_pad(&hasher); ++ if !ct_eq(challenge_value, expected_challenge_b64.as_slice()) { ++ return Err(AuthError::InvalidMessageHash.into()); ++ } ++ ++ // Hash the raw clientDataJSON + #[allow(unused_assignments)] + let mut client_data_hash = [0u8; 32]; + #[cfg(target_os = "solana")] + unsafe { + let _res = pinocchio::syscalls::sol_sha256( +- [client_data_json.as_slice()].as_ptr() as *const u8, ++ [raw_client_data_json].as_ptr() as *const u8, + 1, + client_data_hash.as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { +- let _ = client_data_json; ++ let _ = raw_client_data_json; + client_data_hash = [0u8; 32]; + } + ++ // --- Shared validation (both modes) --- ++ + let auth_data_parser = AuthDataParser::new(authenticator_data_raw)?; + if !auth_data_parser.is_user_present() { + return Err(AuthError::PermissionDenied.into()); + } + +- // Note: We intentionally do NOT check auth_data_parser.counter() (the WebAuthn hardware +- // counter). Synced passkeys (iCloud Keychain, Google Password Manager) may return 0 or +- // non-incrementing values. The program-controlled odometer above provides replay protection. ++ // Note: We intentionally do NOT check the WebAuthn hardware counter. ++ // Synced passkeys (iCloud, Google) may return 0 or non-incrementing values. + +- // Security Validation: +- // Ensure the domain (rp_id_hash) the user provided in the instruction payload actually matches +- // the rpIdHash that the authenticator (Hardware/FaceID) signed over inside authenticatorData. +- if auth_data_parser.rp_id_hash() != computed_rp_id_hash { ++ // Validate rpIdHash in authenticatorData matches the stored rpIdHash. ++ if auth_data_parser.rp_id_hash() != stored_rp_id_hash { + return Err(AuthError::InvalidPubkey.into()); + } + +@@ -199,10 +217,10 @@ impl Authenticator for Secp256r1Authenticator { + let instruction_pubkey_bytes = &auth_data[pubkey_offset..pubkey_offset + 33]; + let expected_pubkey: &[u8; 33] = instruction_pubkey_bytes.try_into().unwrap(); + +- let mut signed_message = Vec::with_capacity(authenticator_data_raw.len() + 32); +- signed_message.extend_from_slice(authenticator_data_raw); +- signed_message.extend_from_slice(&client_data_hash); ++ // The precompile's signed message is authenticator_data ∥ client_data_hash. ++ // Pass the two slices separately to avoid an intermediate Vec allocation. + ++ // Introspect the secp256r1 precompile instruction (must be the previous instruction) + let sysvar_instructions = accounts + .get(sysvar_ix_index) + .ok_or(AuthError::InvalidAuthorityPayload)?; +@@ -225,10 +243,11 @@ impl Authenticator for Secp256r1Authenticator { + verify_secp256r1_instruction_data( + secp_ix.get_instruction_data(), + expected_pubkey, +- &signed_message, ++ authenticator_data_raw, ++ &client_data_hash, + )?; + +- // Signature verified successfully — now commit the counter update ++ // Signature verified successfully — commit the counter update + header.counter = expected_counter; + unsafe { + std::ptr::write_unaligned( +@@ -240,3 +259,52 @@ impl Authenticator for Secp256r1Authenticator { + Ok(()) + } + } ++ ++/// Constant-time byte slice equality. Returns `false` for different lengths; ++/// otherwise XORs every byte pair into an accumulator and compares to zero, ++/// ensuring the comparison takes the same time regardless of where (or if) the ++/// bytes differ. Used for the Mode 1 challenge check. ++#[inline(always)] ++fn ct_eq(a: &[u8], b: &[u8]) -> bool { ++ if a.len() != b.len() { ++ return false; ++ } ++ let mut acc: u8 = 0; ++ for i in 0..a.len() { ++ acc |= a[i] ^ b[i]; ++ } ++ acc == 0 ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::ct_eq; ++ ++ #[test] ++ fn ct_eq_equal() { ++ assert!(ct_eq(b"abc", b"abc")); ++ assert!(ct_eq(b"", b"")); ++ assert!(ct_eq(&[0xFF; 43], &[0xFF; 43])); ++ } ++ ++ #[test] ++ fn ct_eq_different_length() { ++ assert!(!ct_eq(b"abc", b"abcd")); ++ assert!(!ct_eq(b"", b"a")); ++ } ++ ++ #[test] ++ fn ct_eq_differs_at_start() { ++ assert!(!ct_eq(b"xbc", b"abc")); ++ } ++ ++ #[test] ++ fn ct_eq_differs_at_end() { ++ assert!(!ct_eq(b"abx", b"abc")); ++ } ++ ++ #[test] ++ fn ct_eq_differs_at_middle() { ++ assert!(!ct_eq(b"axc", b"abc")); ++ } ++} +diff --git a/program/src/auth/secp256r1/webauthn.rs b/program/src/auth/secp256r1/webauthn.rs +index f8d0371..db1725c 100644 +--- a/program/src/auth/secp256r1/webauthn.rs ++++ b/program/src/auth/secp256r1/webauthn.rs +@@ -1,51 +1,9 @@ +-#[allow(unused_imports)] + use crate::error::AuthError; +-#[allow(unused_imports)] + use pinocchio::program_error::ProgramError; + +-/// Packed flags for clientDataJson reconstruction +-#[derive(Clone, Copy, Debug)] +-#[repr(C)] +-pub struct ClientDataJsonReconstructionParams { +- pub type_and_flags: u8, +-} +- +-impl ClientDataJsonReconstructionParams { +- #[allow(dead_code)] +- const TYPE_CREATE: u8 = 0x00; +- const TYPE_GET: u8 = 0x10; +- const FLAG_CROSS_ORIGIN: u8 = 0x01; +- const FLAG_HTTP_ORIGIN: u8 = 0x02; +- const FLAG_GOOGLE_EXTRA: u8 = 0x04; +- +- pub fn auth_type(&self) -> AuthType { +- if (self.type_and_flags & 0xF0) == Self::TYPE_GET { +- AuthType::Get +- } else { +- AuthType::Create +- } +- } +- +- pub fn is_cross_origin(&self) -> bool { +- self.type_and_flags & Self::FLAG_CROSS_ORIGIN != 0 +- } +- +- pub fn is_http(&self) -> bool { +- self.type_and_flags & Self::FLAG_HTTP_ORIGIN != 0 +- } +- +- pub fn has_google_extra(&self) -> bool { +- self.type_and_flags & Self::FLAG_GOOGLE_EXTRA != 0 +- } +-} +- +-#[derive(Clone, Copy, Debug)] +-pub enum AuthType { +- Create, +- Get, +-} +- +-/// Simple Base64URL encoder without padding ++/// Simple Base64URL encoder without padding. ++/// Used to compare an on-chain-computed challenge against the base64url value ++/// the browser authenticator placed inside clientDataJSON. + pub fn base64url_encode_no_pad(data: &[u8]) -> Vec { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut result = Vec::with_capacity(data.len().div_ceil(3) * 4); +@@ -70,48 +28,6 @@ pub fn base64url_encode_no_pad(data: &[u8]) -> Vec { + result + } + +-/// Reconstructs the clientDataJson +-pub fn reconstruct_client_data_json( +- params: &ClientDataJsonReconstructionParams, +- rp_id: &[u8], +- challenge: &[u8], +-) -> Vec { +- let challenge_b64url = base64url_encode_no_pad(challenge); +- let type_str: &[u8] = match params.auth_type() { +- AuthType::Create => b"webauthn.create", +- AuthType::Get => b"webauthn.get", +- }; +- +- let prefix: &[u8] = if params.is_http() { +- b"http://" +- } else { +- b"https://" +- }; +- let cross_origin: &[u8] = if params.is_cross_origin() { +- b"true" +- } else { +- b"false" +- }; +- +- let mut json = Vec::with_capacity(256); +- json.extend_from_slice(b"{\"type\":\""); +- json.extend_from_slice(type_str); +- json.extend_from_slice(b"\",\"challenge\":\""); +- json.extend_from_slice(&challenge_b64url); +- json.extend_from_slice(b"\",\"origin\":\""); +- json.extend_from_slice(prefix); +- json.extend_from_slice(rp_id); +- json.extend_from_slice(b"\",\"crossOrigin\":"); +- json.extend_from_slice(cross_origin); +- +- if params.has_google_extra() { +- json.extend_from_slice(b",\"other_keys_can_be_added_here\":\"do not compare clientDataJSON against a template. See https://goo.gl/yabPex\""); +- } +- +- json.extend_from_slice(b"}"); +- json +-} +- + /// Minimum authenticator data length: rpIdHash(32) + flags(1) + counter(4) = 37 + pub const AUTH_DATA_MIN_LEN: usize = 37; + +@@ -144,3 +60,536 @@ impl<'a> AuthDataParser<'a> { + u32::from_be_bytes(self.data[33..37].try_into().unwrap()) + } + } ++ ++/// Extracts a top-level string value for a given key from a JSON object. ++/// ++/// Walks `{"key":"value", ...}` looking for the specified key at depth 1. ++/// Returns the value bytes (without quotes). Rejects escaped strings ++/// (backslash inside key or value) to prevent challenge injection. ++pub fn extract_top_level_string_field<'a>( ++ json: &'a [u8], ++ field_name: &[u8], ++) -> Result<&'a [u8], ProgramError> { ++ // Skip leading whitespace ++ let mut i = 0; ++ while i < json.len() && json[i].is_ascii_whitespace() { ++ i += 1; ++ } ++ if i >= json.len() || json[i] != b'{' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ ++ let mut depth: usize = 0; ++ let mut cursor = i; ++ ++ while cursor < json.len() { ++ let byte = json[cursor]; ++ ++ if byte == b'{' { ++ depth += 1; ++ cursor += 1; ++ continue; ++ } ++ if byte == b'}' { ++ if depth == 0 { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ depth -= 1; ++ cursor += 1; ++ continue; ++ } ++ ++ // Only parse keys at the top level (depth == 1) ++ if depth == 1 && byte == b'"' { ++ // Parse key ++ let key_start = cursor + 1; ++ let mut key_end = key_start; ++ while key_end < json.len() { ++ let b = json[key_end]; ++ if b == b'"' { ++ break; ++ } ++ if b == b'\\' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ key_end += 1; ++ } ++ if key_end >= json.len() { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ ++ // Skip past closing quote ++ cursor = key_end + 1; ++ ++ // Skip whitespace before colon ++ while cursor < json.len() && json[cursor].is_ascii_whitespace() { ++ cursor += 1; ++ } ++ if cursor >= json.len() || json[cursor] != b':' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ cursor += 1; ++ ++ // Skip whitespace after colon ++ while cursor < json.len() && json[cursor].is_ascii_whitespace() { ++ cursor += 1; ++ } ++ if cursor >= json.len() { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ ++ // Check if this is the field we want ++ if &json[key_start..key_end] == field_name { ++ // Value must be a string ++ if json[cursor] != b'"' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ let value_start = cursor + 1; ++ let mut value_end = value_start; ++ while value_end < json.len() { ++ let b = json[value_end]; ++ if b == b'"' { ++ return Ok(&json[value_start..value_end]); ++ } ++ if b == b'\\' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ value_end += 1; ++ } ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ ++ // Not our field — skip the value ++ if json[cursor] == b'"' { ++ // String value — skip to closing quote ++ cursor += 1; ++ while cursor < json.len() { ++ let b = json[cursor]; ++ if b == b'"' { ++ cursor += 1; ++ break; ++ } ++ if b == b'\\' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ cursor += 1; ++ } ++ } else { ++ // Non-string value (number, bool, null, object, array) — skip ++ // until the next comma or closing brace at the same depth. ++ // Track nested braces and brackets, and when we encounter a ++ // string, consume it entirely so that `{`, `}`, `[`, `]`, or ++ // `,` inside the string body don't corrupt depth tracking. ++ // ++ // Without the inner string-skip, a payload like ++ // {"tokenBinding":{"id":"x}y"},"challenge":"real"} ++ // would have the `}` inside "x}y" mistakenly close the nested ++ // object and the parser would then mis-locate the top-level ++ // "challenge" entry. ++ let mut nest: usize = 0; ++ while cursor < json.len() { ++ match json[cursor] { ++ b'"' => { ++ // Consume the string; inner quotes/braces/commas ++ // must not affect outer nesting state. ++ cursor += 1; ++ while cursor < json.len() { ++ let b = json[cursor]; ++ if b == b'"' { ++ cursor += 1; ++ break; ++ } ++ if b == b'\\' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ cursor += 1; ++ } ++ continue; ++ } ++ b'{' | b'[' => nest += 1, ++ b'}' | b']' => { ++ if nest == 0 { ++ break; ++ } ++ nest -= 1; ++ } ++ b',' if nest == 0 => break, ++ _ => {} ++ } ++ cursor += 1; ++ } ++ } ++ continue; ++ } ++ ++ cursor += 1; ++ } ++ ++ Err(AuthError::InvalidMessage.into()) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ // ─── extract_top_level_string_field tests ─────────────────────────── ++ ++ #[test] ++ fn test_extract_field_basic() { ++ let json = br#"{"type":"webauthn.get","challenge":"abc123","origin":"https://example.com"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc123" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"origin").unwrap(), ++ b"https://example.com" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_with_bool_value() { ++ let json = ++ br#"{"type":"webauthn.get","challenge":"abc","crossOrigin":false,"origin":"https://x.com"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"origin").unwrap(), ++ b"https://x.com" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_with_nested_object() { ++ // Real Android clientDataJSON has extra fields like androidPackageName ++ let json = ++ br#"{"type":"webauthn.get","challenge":"xyz","origin":"https://a.com","androidPackageName":"com.example.app"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"xyz" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_with_nested_json_object() { ++ // Nested object should be skipped when looking for top-level keys ++ let json = br#"{"nested":{"challenge":"fake"},"type":"webauthn.get","challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_missing_key() { ++ let json = br#"{"type":"webauthn.get"}"#; ++ assert!(extract_top_level_string_field(json, b"challenge").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_rejects_escaped_key() { ++ // Backslash in key → reject (prevents injection) ++ let json = br#"{"ty\"pe":"webauthn.get"}"#; ++ assert!(extract_top_level_string_field(json, b"type").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_rejects_escaped_value() { ++ // Backslash in value → reject ++ let json = br#"{"challenge":"abc\"def"}"#; ++ assert!(extract_top_level_string_field(json, b"challenge").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_rejects_non_string_value() { ++ // challenge is a number, not a string → reject ++ let json = br#"{"challenge":12345}"#; ++ assert!(extract_top_level_string_field(json, b"challenge").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_rejects_empty_input() { ++ assert!(extract_top_level_string_field(b"", b"type").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_rejects_not_object() { ++ assert!(extract_top_level_string_field(b"[1,2,3]", b"type").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_nested_challenge_not_found_at_top() { ++ // challenge only exists inside a nested object — should not be found ++ let json = br#"{"type":"webauthn.get","nested":{"challenge":"sneaky"}}"#; ++ assert!(extract_top_level_string_field(json, b"challenge").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_with_whitespace() { ++ let json = br#"{ "type" : "webauthn.get" , "challenge" : "abc" }"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_google_extra() { ++ // Google Chrome adds this extra field ++ let json = br#"{"type":"webauthn.get","challenge":"abc","origin":"https://x.com","crossOrigin":false,"other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See https://goo.gl/yabPex"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_with_array_value() { ++ // Array value should be skipped properly ++ let json = br#"{"arr":[1,2,3],"challenge":"abc"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_with_nested_array_of_objects() { ++ let json = br#"{"arr":[{"challenge":"fake"},{"x":1}],"challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ // ─── M1 regression: string content inside nested value must not ++ // corrupt depth tracking ────────────────────────────────────── ++ ++ #[test] ++ fn test_extract_skips_string_containing_close_brace_in_nested_object() { ++ // Pre-fix, the `}` inside "x}y" would mistakenly close the nested ++ // object, and the parser would then mis-locate the top-level ++ // "challenge" entry. Post-fix, the inner string is consumed as a ++ // whole so nested depth stays at 1 until the real `}` at end of ++ // the tokenBinding value. ++ let json = br#"{"tokenBinding":{"id":"x}y"},"challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_skips_string_containing_close_bracket() { ++ let json = br#"{"arr":[{"id":"x]y"}],"challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_skips_string_containing_comma_in_nested_object() { ++ // Comma inside a string at non-zero depth — should not affect anything, ++ // but once we `continue` to the top of the skip loop we could be fooled ++ // into thinking the comma terminates the value. ++ let json = br#"{"obj":{"k":"a,b,c"},"challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_skips_string_containing_open_brace_in_array() { ++ let json = br#"{"arr":[{"id":"x{y"}],"challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_challenge_before_tokenbinding_still_works() { ++ // Safety: ensure the fix doesn't break the common happy path. ++ let json = br#"{"type":"webauthn.get","challenge":"real","tokenBinding":{"id":"x}y"}}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_rejects_backslash_in_skipped_string_inside_nested() { ++ // Backslashes are rejected in string values everywhere, including ++ // strings inside a skipped nested object. This prevents escape ++ // injection reaching the parser via nested fields. ++ let json = br#"{"obj":{"id":"a\"b"},"challenge":"real"}"#; ++ assert!(extract_top_level_string_field(json, b"challenge").is_err()); ++ } ++ ++ // ─── base64url_encode_no_pad tests ────────────────────────────────── ++ ++ #[test] ++ fn test_base64url_encode_empty() { ++ assert_eq!(base64url_encode_no_pad(&[]), b""); ++ } ++ ++ #[test] ++ fn test_base64url_encode_known_vectors() { ++ // "f" → "Zg" ++ assert_eq!(base64url_encode_no_pad(b"f"), b"Zg"); ++ // "fo" → "Zm8" ++ assert_eq!(base64url_encode_no_pad(b"fo"), b"Zm8"); ++ // "foo" → "Zm9v" ++ assert_eq!(base64url_encode_no_pad(b"foo"), b"Zm9v"); ++ } ++ ++ #[test] ++ fn test_base64url_encode_32_bytes() { ++ // SHA256 output (32 bytes) → 43 base64url chars (no padding) ++ let data = [0x11u8; 32]; ++ let encoded = base64url_encode_no_pad(&data); ++ assert_eq!(encoded.len(), 43); ++ // Verify no padding characters ++ assert!(!encoded.contains(&b'=')); ++ // Verify URL-safe: no + or / ++ assert!(!encoded.contains(&b'+')); ++ assert!(!encoded.contains(&b'/')); ++ } ++ ++ #[test] ++ fn test_base64url_uses_url_safe_chars() { ++ // 0xFB, 0xFF → should produce '-' and '_' instead of '+' and '/' ++ let data = [0xFB, 0xFF, 0xFE]; ++ let encoded = base64url_encode_no_pad(&data); ++ let encoded_str = std::str::from_utf8(&encoded).unwrap(); ++ assert!( ++ !encoded_str.contains('+') && !encoded_str.contains('/'), ++ "Must use URL-safe alphabet" ++ ); ++ } ++ ++ // ─── AuthDataParser tests ─────────────────────────────────────────── ++ ++ #[test] ++ fn test_auth_data_parser_basic() { ++ let mut data = [0u8; 37]; ++ // rpIdHash = first 32 bytes (zeros) ++ data[32] = 0x05; // flags: user present (0x01) + user verified (0x04) ++ data[33..37].copy_from_slice(&[0, 0, 0, 42]); // counter = 42 (big-endian) ++ ++ let parser = AuthDataParser::new(&data).unwrap(); ++ assert!(parser.is_user_present()); ++ assert!(parser.is_user_verified()); ++ assert_eq!(parser.counter(), 42); ++ assert_eq!(parser.rp_id_hash(), &[0u8; 32]); ++ } ++ ++ #[test] ++ fn test_auth_data_parser_no_flags() { ++ let data = [0u8; 37]; ++ let parser = AuthDataParser::new(&data).unwrap(); ++ assert!(!parser.is_user_present()); ++ assert!(!parser.is_user_verified()); ++ } ++ ++ #[test] ++ fn test_auth_data_parser_too_short() { ++ let data = [0u8; 36]; // Less than 37 ++ assert!(AuthDataParser::new(&data).is_err()); ++ } ++ ++ #[test] ++ fn test_auth_data_parser_with_extensions() { ++ // Real authenticators may return > 37 bytes (with extensions) ++ let mut data = [0u8; 100]; ++ data[32] = 0x41; // user present + attested credential data ++ let parser = AuthDataParser::new(&data).unwrap(); ++ assert!(parser.is_user_present()); ++ } ++ ++ // ─── Real-world clientDataJSON samples ────────────────────────────── ++ ++ #[test] ++ fn test_extract_from_chrome_sample() { ++ let json = br#"{"type":"webauthn.get","challenge":"dGVzdC1jaGFsbGVuZ2U","origin":"https://lazorkit.app","crossOrigin":false}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"dGVzdC1jaGFsbGVuZ2U" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"origin").unwrap(), ++ b"https://lazorkit.app" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_from_android_sample() { ++ // Android may include androidPackageName and topOrigin ++ let json = br#"{"type":"webauthn.get","challenge":"abc123","origin":"https://example.com","androidPackageName":"com.example.app","topOrigin":"https://example.com"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc123" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"androidPackageName").unwrap(), ++ b"com.example.app" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_from_safari_no_crossorigin() { ++ // Safari may omit crossOrigin entirely ++ let json = ++ br#"{"type":"webauthn.get","challenge":"xyz","origin":"https://lazorkit.app"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"xyz" ++ ); ++ // crossOrigin field doesn't exist → error ++ assert!(extract_top_level_string_field(json, b"crossOrigin").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_rejects_webauthn_create_type() { ++ let json = br#"{"type":"webauthn.create","challenge":"abc"}"#; ++ let type_val = extract_top_level_string_field(json, b"type").unwrap(); ++ assert_ne!(type_val, b"webauthn.get"); ++ assert_eq!(type_val, b"webauthn.create"); ++ } ++} +diff --git a/program/src/compact.rs b/program/src/compact.rs +index fd4db2f..4b2cca9 100644 +--- a/program/src/compact.rs ++++ b/program/src/compact.rs +@@ -26,6 +26,8 @@ pub struct CompactInstruction { + } + + /// Reference version of CompactInstruction that borrows its data. ++/// Used by the Execute hot path to avoid Vec allocations during parse ++/// + decompress. + /// + /// # Fields + /// * `program_id_index` - Index of the program ID in the account list +@@ -37,6 +39,108 @@ pub struct CompactInstructionRef<'a> { + pub data: &'a [u8], + } + ++impl<'a> CompactInstructionRef<'a> { ++ /// Deserialize a CompactInstructionRef from bytes — zero-copy, the ++ /// returned struct borrows from `bytes`. ++ /// Format: [program_id_index: u8][num_accounts: u8][accounts...][data_len: u16][data...] ++ pub fn from_bytes(bytes: &'a [u8]) -> Result<(Self, &'a [u8]), ProgramError> { ++ if bytes.len() < 4 { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ ++ let program_id_index = bytes[0]; ++ let num_accounts = bytes[1] as usize; ++ ++ if bytes.len() < 2 + num_accounts + 2 { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ ++ let accounts = &bytes[2..2 + num_accounts]; ++ let data_len_offset = 2 + num_accounts; ++ let data_len = ++ u16::from_le_bytes([bytes[data_len_offset], bytes[data_len_offset + 1]]) as usize; ++ ++ let data_start = data_len_offset + 2; ++ if bytes.len() < data_start + data_len { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ ++ let data = &bytes[data_start..data_start + data_len]; ++ let rest = &bytes[data_start + data_len..]; ++ ++ Ok(( ++ CompactInstructionRef { ++ program_id_index, ++ accounts, ++ data, ++ }, ++ rest, ++ )) ++ } ++ ++ /// Decompress into a full Instruction without cloning the instruction ++ /// data. `account_infos` lifetime 'b is tracked separately from the ++ /// instruction-data lifetime 'a. ++ pub fn decompress<'b>( ++ &self, ++ account_infos: &'b [AccountInfo], ++ ) -> Result, ProgramError> { ++ if (self.program_id_index as usize) >= account_infos.len() { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ let program_id = account_infos[self.program_id_index as usize].key(); ++ ++ let mut accounts: Vec<&AccountInfo> = Vec::with_capacity(self.accounts.len()); ++ for &index in self.accounts { ++ if (index as usize) >= account_infos.len() { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ accounts.push(&account_infos[index as usize]); ++ } ++ ++ Ok(DecompressedInstructionRef { ++ program_id, ++ accounts, ++ data: self.data, ++ }) ++ } ++} ++ ++/// Zero-copy variant of DecompressedInstruction. `data` borrows from the ++/// original instruction_data — no clone. ++pub struct DecompressedInstructionRef<'a, 'b> { ++ pub program_id: &'b Pubkey, ++ pub accounts: Vec<&'b AccountInfo>, ++ pub data: &'a [u8], ++} ++ ++/// Parse + return total bytes consumed (ref-based, no allocations for ++/// account index bytes or instruction data). ++pub fn parse_compact_instructions_ref_with_len<'a>( ++ bytes: &'a [u8], ++) -> Result<(Vec>, usize), ProgramError> { ++ if bytes.is_empty() { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ ++ let num_instructions = bytes[0] as usize; ++ if num_instructions > MAX_COMPACT_INSTRUCTIONS { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ ++ let mut instructions = Vec::with_capacity(num_instructions); ++ let mut remaining = &bytes[1..]; ++ ++ for _ in 0..num_instructions { ++ let (ix, rest) = CompactInstructionRef::from_bytes(remaining)?; ++ instructions.push(ix); ++ remaining = rest; ++ } ++ ++ let consumed = bytes.len() - remaining.len(); ++ Ok((instructions, consumed)) ++} ++ + impl CompactInstructions { + /// Serializes the compact instructions into bytes. + /// +@@ -52,8 +156,12 @@ impl CompactInstructions { + /// # Returns + /// * `Vec` - Serialized instruction data + pub fn into_bytes(&self) -> Vec { ++ // Lengths are encoded as u8 — values > 255 would silently truncate and corrupt ++ // the instruction stream on deserialization. Enforce at runtime, not just debug. ++ assert!(self.inner_instructions.len() <= 255, "instruction count exceeds u8 max"); + let mut bytes = vec![self.inner_instructions.len() as u8]; + for ix in self.inner_instructions.iter() { ++ assert!(ix.accounts.len() <= 255, "account count exceeds u8 max"); + bytes.push(ix.program_id_index); + bytes.push(ix.accounts.len() as u8); + bytes.extend(ix.accounts.iter()); +@@ -105,6 +213,7 @@ impl CompactInstruction { + + /// Serialize this CompactInstruction to bytes + pub fn to_bytes(&self) -> Vec { ++ assert!(self.accounts.len() <= 255, "account count exceeds u8 max"); + let mut bytes = Vec::with_capacity(4 + self.accounts.len() + self.data.len()); + bytes.push(self.program_id_index); + bytes.push(self.accounts.len() as u8); +@@ -150,14 +259,31 @@ pub struct DecompressedInstruction<'a> { + pub data: Vec, // Owned data to avoid lifetime issues + } + ++/// Maximum number of compact instructions per Execute call. ++/// Prevents compute-unit exhaustion DoS. ++pub const MAX_COMPACT_INSTRUCTIONS: usize = 16; ++ + /// Parse multiple CompactInstructions from bytes + /// Format: [num_instructions: u8][instruction_0][instruction_1]... + pub fn parse_compact_instructions(bytes: &[u8]) -> Result, ProgramError> { ++ parse_compact_instructions_with_len(bytes).map(|(ixs, _)| ixs) ++} ++ ++/// Parse + return total bytes consumed. Used by the Execute processor to ++/// split the instruction data into the compact-instructions prefix and the ++/// auth payload suffix without re-serializing. ++pub fn parse_compact_instructions_with_len( ++ bytes: &[u8], ++) -> Result<(Vec, usize), ProgramError> { + if bytes.is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + + let num_instructions = bytes[0] as usize; ++ if num_instructions > MAX_COMPACT_INSTRUCTIONS { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ + let mut instructions = Vec::with_capacity(num_instructions); + let mut remaining = &bytes[1..]; + +@@ -167,7 +293,8 @@ pub fn parse_compact_instructions(bytes: &[u8]) -> Result = (0..=255).collect(); ++ // Test with 255 accounts (the valid u8 max) ++ let accounts: Vec = (0..255).collect(); + let ix = CompactInstruction { + program_id_index: 0, +- accounts, ++ accounts: accounts.clone(), + data: vec![1], + }; + + let bytes = ix.to_bytes(); + let (deserialized, _) = CompactInstruction::from_bytes(&bytes).unwrap(); ++ assert_eq!(deserialized.accounts.len(), 255); ++ } + +- // Note: accounts.len() wraps to 0 when cast to u8! +- // This is a known limitation - can't have exactly 256 accounts +- assert_eq!(deserialized.accounts.len(), 0); // Wraps around! ++ #[test] ++ #[should_panic(expected = "account count exceeds u8 max")] ++ fn test_256_accounts_panics() { ++ // 256 accounts would silently truncate to 0 via `as u8`. ++ // The assert! guard must catch this. ++ let accounts: Vec = (0..=255).collect(); // 256 elements ++ let ix = CompactInstruction { ++ program_id_index: 0, ++ accounts, ++ data: vec![1], ++ }; ++ let _ = ix.to_bytes(); // should panic + } + + #[test] +diff --git a/program/src/error.rs b/program/src/error.rs +index f555307..c3b483b 100644 +--- a/program/src/error.rs ++++ b/program/src/error.rs +@@ -22,6 +22,21 @@ pub enum AuthError { + UnauthorizedReclaim = 3017, + DeferredAuthorizationNotExpired = 3018, + InvalidSessionAccount = 3019, ++ // Session action errors (codes aligned with lazorkit-protocol for unified SDK error decoding) ++ ActionBufferInvalid = 3020, ++ ActionProgramNotWhitelisted = 3021, ++ ActionProgramBlacklisted = 3022, ++ ActionSolMaxPerTxExceeded = 3023, ++ ActionSolLimitExceeded = 3024, ++ ActionSolRecurringLimitExceeded = 3025, ++ ActionTokenLimitExceeded = 3026, ++ ActionTokenRecurringLimitExceeded = 3027, ++ ActionWhitelistBlacklistConflict = 3028, ++ ActionTokenMaxPerTxExceeded = 3029, ++ // Session vault + token invariants (defense against System::Assign / SetAuthority escapes) ++ SessionVaultOwnerChanged = 3030, ++ SessionVaultDataLenChanged = 3031, ++ SessionTokenAuthorityChanged = 3032, + } + + impl From for ProgramError { +diff --git a/program/src/instruction.rs b/program/src/instruction.rs +index 21742cc..0c970f0 100644 +--- a/program/src/instruction.rs ++++ b/program/src/instruction.rs +@@ -26,13 +26,13 @@ pub enum ProgramIx { + }, + + /// Add a new authority to the wallet +- #[account(0, signer, name = "payer", desc = "Transaction payer")] ++ #[account(0, signer, writable, name = "payer", desc = "Payer and rent contributor")] + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, +- signer, ++ writable, + name = "admin_authority", +- desc = "Admin authority PDA authorizing this action" ++ desc = "Admin authority PDA authorizing this action (counter incremented)" + )] + #[account( + 3, +@@ -57,13 +57,13 @@ pub enum ProgramIx { + }, + + /// Remove an authority from the wallet +- #[account(0, signer, name = "payer", desc = "Transaction payer")] ++ #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, +- signer, ++ writable, + name = "admin_authority", +- desc = "Admin authority PDA authorizing this action" ++ desc = "Admin authority PDA authorizing this action (counter incremented)" + )] + #[account( + 3, +@@ -86,7 +86,7 @@ pub enum ProgramIx { + RemoveAuthority, + + /// Transfer ownership (atomic swap of Owner role) +- #[account(0, signer, name = "payer", desc = "Transaction payer")] ++ #[account(0, signer, writable, name = "payer", desc = "Payer and rent contributor")] + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, +@@ -100,10 +100,16 @@ pub enum ProgramIx { + name = "new_owner_authority", + desc = "New owner authority PDA to be created" + )] +- #[account(4, name = "system_program", desc = "System Program")] +- #[account(5, name = "rent_sysvar", desc = "Rent Sysvar")] + #[account( +- 6, ++ 4, ++ writable, ++ name = "refund_destination", ++ desc = "Account to receive rent refund from closed current owner" ++ )] ++ #[account(5, name = "system_program", desc = "System Program")] ++ #[account(6, name = "rent_sysvar", desc = "Rent Sysvar")] ++ #[account( ++ 7, + signer, + optional, + name = "authorizer_signer", +@@ -116,14 +122,15 @@ pub enum ProgramIx { + }, + + /// Execute transactions +- #[account(0, signer, name = "payer", desc = "Transaction payer")] ++ #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, ++ writable, + name = "authority", +- desc = "Authority or Session PDA authorizing execution" ++ desc = "Authority or Session PDA authorizing execution (counter incremented)" + )] +- #[account(3, name = "vault", desc = "Vault PDA")] ++ #[account(3, writable, name = "vault", desc = "Vault PDA (signer for CPI, lamports debited)")] + #[account( + 4, + optional, +@@ -142,9 +149,9 @@ pub enum ProgramIx { + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, +- signer, ++ writable, + name = "admin_authority", +- desc = "Admin/Owner authority PDA authorizing logic" ++ desc = "Admin/Owner authority PDA authorizing logic (counter incremented)" + )] + #[account(3, writable, name = "session", desc = "New session PDA to be created")] + #[account(4, name = "system_program", desc = "System Program")] +@@ -202,13 +209,7 @@ pub enum ProgramIx { + /// + /// Verifies compact instructions against stored hashes, executes via CPI + /// with vault PDA signing, then closes the DeferredExec account. +- #[account( +- 0, +- signer, +- writable, +- name = "payer", +- desc = "Transaction payer" +- )] ++ #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account(2, writable, name = "vault", desc = "Vault PDA (signer for CPI)")] + #[account( +@@ -223,9 +224,7 @@ pub enum ProgramIx { + name = "refund_destination", + desc = "Account to receive rent refund from closed DeferredExec" + )] +- ExecuteDeferred { +- instructions: Vec, +- }, ++ ExecuteDeferred { instructions: Vec }, + + /// Reclaim an expired DeferredExec account and refund rent + /// +@@ -253,29 +252,15 @@ pub enum ProgramIx { + /// Revoke a session key early (before expiry) + /// + /// Only Owner or Admin can revoke. Closes the session account and refunds rent. +- #[account( +- 0, +- signer, +- name = "payer", +- desc = "Transaction payer" +- )] +- #[account( +- 1, +- name = "wallet", +- desc = "Wallet PDA" +- )] ++ #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] ++ #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, + writable, + name = "admin_authority", + desc = "Owner/Admin authority PDA (counter incremented for Secp256r1)" + )] +- #[account( +- 3, +- writable, +- name = "session", +- desc = "Session PDA to revoke" +- )] ++ #[account(3, writable, name = "session", desc = "Session PDA to revoke")] + #[account( + 4, + writable, +@@ -341,7 +326,9 @@ pub enum LazorKitInstruction { + /// 2. `[]` Wallet PDA + /// 3. `[writable]` Current Owner Authority PDA + /// 4. `[writable]` New Owner Authority PDA +- /// 5. `[]` System Program ++ /// 5. `[writable]` Refund Destination ++ /// 6. `[]` System Program ++ /// 7. `[]` Rent Sysvar + TransferOwnership { + new_type: u8, + new_pubkey: [u8; 33], +diff --git a/program/src/lib.rs b/program/src/lib.rs +index aad119c..2a41038 100644 +--- a/program/src/lib.rs ++++ b/program/src/lib.rs +@@ -1,5 +1,22 @@ + #![allow(unexpected_cfgs)] + ++#[cfg(not(feature = "no-entrypoint"))] ++use {default_env::default_env, solana_security_txt::security_txt}; ++ ++#[cfg(not(feature = "no-entrypoint"))] ++security_txt! { ++ name: "LazorKit Smart Wallet", ++ project_url: "https://lazorkit.com", ++ contacts: "email:security@lazorkit.app,link:https://github.com/lazor-kit/program-v2/security/advisories/new", ++ policy: "https://github.com/lazor-kit/program-v2/blob/main/SECURITY.md", ++ ++ preferred_languages: "en,vi", ++ source_code: "https://github.com/lazor-kit/program-v2", ++ source_revision: default_env!("GITHUB_SHA", ""), ++ source_release: default_env!("GITHUB_REF_NAME", ""), ++ auditors: "Accretion Labs (Solana Foundation) — https://github.com/lazor-kit/program-v2/blob/main/audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf" ++} ++ + pub mod auth; + pub mod compact; + pub mod entrypoint; +diff --git a/program/src/processor/create_session.rs b/program/src/processor/create_session.rs +index ed378cb..175b06b 100644 +--- a/program/src/processor/create_session.rs ++++ b/program/src/processor/create_session.rs +@@ -14,14 +14,24 @@ use crate::{ + ed25519::Ed25519Authenticator, secp256r1::Secp256r1Authenticator, traits::Authenticator, + }, + error::AuthError, +- state::{authority::AuthorityAccountHeader, session::SessionAccount, AccountDiscriminator}, ++ state::{ ++ action::validate_actions_buffer, ++ authority::AuthorityAccountHeader, ++ session::{SessionAccount, SESSION_HEADER_SIZE}, ++ AccountDiscriminator, ++ }, + }; + + /// Arguments for the `CreateSession` instruction. + /// + /// Layout: +-/// - `session_key`: The public key of the ephemeral session signer. +-/// - `expires_at`: The absolute slot height when this session expires. ++/// - `session_key`: The public key of the ephemeral session signer (32 bytes). ++/// - `expires_at`: The absolute slot height when this session expires (8 bytes). ++/// - `actions_len`: Length of the actions buffer in bytes (2 bytes, u16 LE). 0 = no actions. ++/// - `actions`: Raw actions buffer (variable, `actions_len` bytes). ++/// ++/// Total fixed: 42 bytes minimum. Backwards compatible: old clients sending 40 bytes ++/// will have actions_len=0 (no actions). + #[repr(C, align(8))] + #[derive(NoPadding)] + pub struct CreateSessionArgs { +@@ -29,23 +39,75 @@ pub struct CreateSessionArgs { + pub expires_at: u64, + } + +-impl CreateSessionArgs { ++/// Parsed session creation arguments including optional actions. ++pub struct ParsedCreateSessionArgs { ++ pub session_key: [u8; 32], ++ pub expires_at: u64, ++ /// Raw actions buffer bytes (empty if no actions). ++ pub actions_bytes: Vec, ++ /// Byte offset where the actions section ends in instruction_data. ++ /// Everything after this is auth_payload for Secp256r1. ++ pub args_end_offset: usize, ++} ++ ++impl ParsedCreateSessionArgs { + pub fn from_bytes(data: &[u8]) -> Result { + if data.len() < 40 { + return Err(ProgramError::InvalidInstructionData); + } +- // args are: [session_key(32)][expires_at(8)] +- let (key_bytes, rest) = data.split_at(32); +- let (alloc_bytes, _) = rest.split_at(8); + + let mut session_key = [0u8; 32]; +- session_key.copy_from_slice(key_bytes); +- +- let expires_at = u64::from_le_bytes(alloc_bytes.try_into().unwrap()); ++ session_key.copy_from_slice(&data[..32]); ++ let expires_at = u64::from_le_bytes(data[32..40].try_into().unwrap()); ++ ++ // Check for actions buffer ++ if data.len() >= 42 { ++ let actions_len = u16::from_le_bytes(data[40..42].try_into().unwrap()) as usize; ++ ++ // Cap actions buffer size to prevent BPF heap exhaustion. ++ // 16 actions * max ~128 bytes each = 2048 is generous. ++ // The BPF heap is 32KB; allocating 64KB (u16 max) would OOM. ++ const MAX_ACTIONS_BUFFER_SIZE: usize = 2048; ++ if actions_len > MAX_ACTIONS_BUFFER_SIZE { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ ++ if actions_len > 0 { ++ let actions_start = 42; ++ let actions_end = actions_start + actions_len; ++ ++ if data.len() < actions_end { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ ++ let actions_bytes = data[actions_start..actions_end].to_vec(); ++ ++ // Validate actions buffer at creation time ++ validate_actions_buffer(&actions_bytes)?; ++ ++ return Ok(Self { ++ session_key, ++ expires_at, ++ actions_bytes, ++ args_end_offset: actions_end, ++ }); ++ } ++ ++ // actions_len == 0 ++ return Ok(Self { ++ session_key, ++ expires_at, ++ actions_bytes: Vec::new(), ++ args_end_offset: 42, ++ }); ++ } + ++ // Legacy format: exactly 40 bytes, no actions + Ok(Self { + session_key, + expires_at, ++ actions_bytes: Vec::new(), ++ args_end_offset: 40, + }) + } + } +@@ -53,11 +115,13 @@ impl CreateSessionArgs { + /// Processes the `CreateSession` instruction. + /// + /// Creates a temporary `Session` account that facilitates limited-scope execution (Spender role). ++/// Optional actions (permissions) can be attached to restrict what the session can do. + /// + /// # Logic: + /// 1. Verifies the authorizing authority (must be Owner or Admin). +-/// 2. Derives a fresh Session PDA from `["session", wallet, session_key]`. +-/// 3. Allocates and initializes the Session account with expiry. ++/// 2. Validates optional actions buffer. ++/// 3. Derives a fresh Session PDA from `["session", wallet, session_key]`. ++/// 4. Allocates and initializes the Session account with expiry and actions. + /// + /// # Accounts: + /// 1. `[signer, writable]` Payer: Pays for rent. +@@ -71,7 +135,7 @@ pub fn process( + accounts: &[AccountInfo], + instruction_data: &[u8], + ) -> ProgramResult { +- let args = CreateSessionArgs::from_bytes(instruction_data)?; ++ let args = ParsedCreateSessionArgs::from_bytes(instruction_data)?; + + let account_info_iter = &mut accounts.iter(); + let payer = account_info_iter +@@ -115,9 +179,6 @@ pub fn process( + return Err(ProgramError::InvalidAccountData); + } + +- // Verify Authorizer +- // Check removed: conditional writable check inside match +- + let auth_data = unsafe { authorizer_pda.borrow_mut_data_unchecked() }; + + // Safe Copy of Header using read_unaligned +@@ -154,35 +215,36 @@ pub fn process( + } + + // Authenticate Authorizer +- +- // We assume CreateSession instruction data AFTER the args is payload for Secp256r1 if any +- let payload_offset = std::mem::size_of::(); +- let authority_payload = if instruction_data.len() > payload_offset { +- &instruction_data[payload_offset..] ++ // instruction_data layout: [args(40)][actions_len(2)][actions(N)][auth_payload...] ++ // args.args_end_offset points to the end of the args+actions section. ++ let data_payload = &instruction_data[..args.args_end_offset]; ++ let authority_payload = if instruction_data.len() > args.args_end_offset { ++ &instruction_data[args.args_end_offset..] + } else { + &[] + }; + +- // But wait, `CreateSessionArgs` consumes 40 bytes. +- // `instruction_data` passed here is whatever follows the discriminator. +- // `Execute` passes compact instructions. +- // Here we pass args. +- +- // For Secp256r1, we need to distinguish args from auth payload. +- // The instruction format is [discriminator][args][payload]. +- // `instruction_data` here is [args][payload]. +- let data_payload = &instruction_data[..payload_offset]; +- +- // Include payer in signed payload to prevent payer swap +- let mut ed25519_payload = Vec::with_capacity(64); ++ // Ed25519 signed payload — includes payer + session_key + actions. ++ // Note: Ed25519Authenticator only checks that the authority keypair is a tx signer, ++ // so this payload is not cryptographically verified. The protection is that only the ++ // keypair holder can sign the transaction. For Secp256r1, the data_payload IS verified. ++ let mut ed25519_payload = Vec::with_capacity(64 + args.actions_bytes.len()); + ed25519_payload.extend_from_slice(payer.key().as_ref()); + ed25519_payload.extend_from_slice(&args.session_key); ++ ed25519_payload.extend_from_slice(&args.actions_bytes); + + match auth_header.authority_type { + 0 => { +- // Ed25519: Include payer + session_key in signed payload +- Ed25519Authenticator.authenticate(accounts, auth_data, &[], &ed25519_payload, &[5], program_id)?; +- }, ++ // Ed25519: Include payer + session_key + actions in signed payload ++ Ed25519Authenticator.authenticate( ++ accounts, ++ auth_data, ++ &[], ++ &ed25519_payload, ++ &[5], ++ program_id, ++ )?; ++ } + 1 => { + // Secp256r1: Include payer in data_payload + let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 32); +@@ -197,7 +259,7 @@ pub fn process( + &[5], + program_id, + )?; +- }, ++ } + _ => return Err(AuthError::InvalidAuthenticationKind.into()), + } + +@@ -211,8 +273,8 @@ pub fn process( + } + check_zero_data(session_pda, ProgramError::AccountAlreadyInitialized)?; + +- // Create Session Account +- let space = std::mem::size_of::(); ++ // Create Session Account — variable size if actions are present ++ let space = SESSION_HEADER_SIZE + args.actions_bytes.len(); + let session_rent = rent.minimum_balance(space); + + let bump_arr = [bump]; +@@ -245,14 +307,20 @@ pub fn process( + expires_at: args.expires_at, + }; + +- // Safe write ++ // Write fixed header + let session_bytes = unsafe { + std::slice::from_raw_parts( + &session as *const SessionAccount as *const u8, + std::mem::size_of::(), + ) + }; +- data[0..std::mem::size_of::()].copy_from_slice(session_bytes); ++ data[..SESSION_HEADER_SIZE].copy_from_slice(session_bytes); ++ ++ // Write actions buffer (if any) ++ if !args.actions_bytes.is_empty() { ++ data[SESSION_HEADER_SIZE..SESSION_HEADER_SIZE + args.actions_bytes.len()] ++ .copy_from_slice(&args.actions_bytes); ++ } + + Ok(()) + } +@@ -264,20 +332,140 @@ mod tests { + #[test] + fn test_create_session_args_from_bytes() { + let mut data = Vec::new(); +- // session_key(32) + expires_at(8) + let session_key = [7u8; 32]; + let expires_at = 12345678u64; + data.extend_from_slice(&session_key); + data.extend_from_slice(&expires_at.to_le_bytes()); + +- let args = CreateSessionArgs::from_bytes(&data).unwrap(); ++ let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); + assert_eq!(args.session_key, session_key); + assert_eq!(args.expires_at, expires_at); ++ assert!(args.actions_bytes.is_empty()); ++ assert_eq!(args.args_end_offset, 40); + } + + #[test] + fn test_create_session_args_too_short() { +- let data = vec![0u8; 39]; // Need 40 +- assert!(CreateSessionArgs::from_bytes(&data).is_err()); ++ let data = vec![0u8; 39]; ++ assert!(ParsedCreateSessionArgs::from_bytes(&data).is_err()); ++ } ++ ++ #[test] ++ fn test_create_session_args_with_no_actions() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&[7u8; 32]); // session_key ++ data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at ++ data.extend_from_slice(&0u16.to_le_bytes()); // actions_len = 0 ++ ++ let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); ++ assert!(args.actions_bytes.is_empty()); ++ assert_eq!(args.args_end_offset, 42); ++ } ++ ++ #[test] ++ fn test_create_session_args_with_actions() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&[7u8; 32]); // session_key ++ data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at ++ ++ // Build a SolMaxPerTx action: header(11) + data(8) = 19 bytes ++ let mut actions = Vec::new(); ++ actions.push(3u8); // type = SolMaxPerTx ++ actions.extend_from_slice(&8u16.to_le_bytes()); // data_len ++ actions.extend_from_slice(&0u64.to_le_bytes()); // expires_at ++ actions.extend_from_slice(&500_000u64.to_le_bytes()); // max ++ ++ data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); // actions_len ++ data.extend_from_slice(&actions); ++ ++ let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); ++ assert_eq!(args.actions_bytes.len(), 19); ++ assert_eq!(args.args_end_offset, 42 + 19); ++ } ++ ++ #[test] ++ fn test_create_session_args_with_invalid_actions() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&[7u8; 32]); // session_key ++ data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at ++ ++ // Invalid action type ++ let mut actions = Vec::new(); ++ actions.push(99u8); // bad type ++ actions.extend_from_slice(&8u16.to_le_bytes()); ++ actions.extend_from_slice(&0u64.to_le_bytes()); ++ actions.extend_from_slice(&500_000u64.to_le_bytes()); ++ ++ data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); ++ data.extend_from_slice(&actions); ++ ++ assert!(ParsedCreateSessionArgs::from_bytes(&data).is_err()); ++ } ++ ++ #[test] ++ fn test_create_session_args_with_trailing_auth_payload() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&[7u8; 32]); // session_key ++ data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at ++ ++ // SolMaxPerTx action ++ let mut actions = Vec::new(); ++ actions.push(3u8); ++ actions.extend_from_slice(&8u16.to_le_bytes()); ++ actions.extend_from_slice(&0u64.to_le_bytes()); ++ actions.extend_from_slice(&500_000u64.to_le_bytes()); ++ ++ data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); ++ data.extend_from_slice(&actions); ++ ++ // Simulate trailing auth payload ++ data.extend_from_slice(&[0xAA; 50]); ++ ++ let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); ++ assert_eq!(args.actions_bytes.len(), 19); ++ assert_eq!(args.args_end_offset, 42 + 19); ++ // Trailing 50 bytes would be auth_payload — not parsed here ++ } ++ ++ #[test] ++ fn test_actions_len_exceeds_cap_rejected() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&[7u8; 32]); // session_key ++ data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at ++ ++ // actions_len = 3000 > MAX_ACTIONS_BUFFER_SIZE (2048) ++ data.extend_from_slice(&3000u16.to_le_bytes()); ++ // Pad enough bytes so the length check doesn't fail first ++ data.extend_from_slice(&vec![0u8; 3000]); ++ ++ let result = ParsedCreateSessionArgs::from_bytes(&data); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_actions_len_at_cap_allowed() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&[7u8; 32]); // session_key ++ data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at ++ ++ // Build a valid action buffer that's under 2048 ++ // 16 ProgramWhitelist actions = 16 * (11 header + 32 data) = 16 * 43 = 688 bytes ++ let mut actions = Vec::new(); ++ for i in 0..16u8 { ++ let mut prog = [0u8; 32]; ++ prog[0] = i; ++ actions.push(10u8); // ProgramWhitelist type ++ actions.extend_from_slice(&32u16.to_le_bytes()); // data_len ++ actions.extend_from_slice(&0u64.to_le_bytes()); // expires_at ++ actions.extend_from_slice(&prog); ++ } ++ assert!(actions.len() <= 2048); ++ ++ data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); ++ data.extend_from_slice(&actions); ++ ++ let result = ParsedCreateSessionArgs::from_bytes(&data); ++ assert!(result.is_ok()); ++ assert_eq!(result.unwrap().actions_bytes.len(), actions.len()); + } + } +diff --git a/program/src/processor/create_wallet.rs b/program/src/processor/create_wallet.rs +index e16b42e..1d70f5d 100644 +--- a/program/src/processor/create_wallet.rs ++++ b/program/src/processor/create_wallet.rs +@@ -79,10 +79,13 @@ pub fn process( + + let (id_seed, full_auth_data) = match args.authority_type { + 0 => { +- if rest.len() != 32 { ++ // Use minimum-length check (consistent with AddAuthority / TransferOwnership). ++ // Exact-length check would reject clients that append trailing context bytes. ++ if rest.len() < 32 { + return Err(ProgramError::InvalidInstructionData); + } +- (rest, rest) ++ let (pubkey, _) = rest.split_at(32); ++ (pubkey, pubkey) + }, + 1 => { + // [credential_id_hash(32)] [pubkey(33)] [rpIdLen(1)] [rpId(N)] +@@ -91,6 +94,12 @@ pub fn process( + } + let (credential_id_hash, rest_after_cred) = rest.split_at(32); + let rp_id_len = rest_after_cred[33] as usize; ++ // Enforce a sane upper bound: max valid domain name is 253 chars. ++ // Without this an attacker-controlled payer could create a 369-byte ++ // authority account with 255 bytes of arbitrary rpId data. ++ if rp_id_len == 0 || rp_id_len > 253 { ++ return Err(ProgramError::InvalidInstructionData); ++ } + let total_auth_data = 32 + 33 + 1 + rp_id_len; + if rest.len() < total_auth_data { + return Err(ProgramError::InvalidInstructionData); +@@ -188,11 +197,18 @@ pub fn process( + } + + // --- 2. Initialize Authority Account --- +- // Authority accounts have a variable size depending on the authority type (e.g., Secp256r1 keys are larger). ++ // Fixed sizes per auth type: ++ // Ed25519 = header(48) + pubkey(32) = 80 bytes ++ // Secp256r1 = header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145 bytes ++ // ++ // For Secp256r1 we hash rpId once at creation and store the digest, so ++ // every subsequent Execute saves one sol_sha256 syscall. + let header_size = std::mem::size_of::(); +- let variable_size = full_auth_data.len(); +- +- let auth_space = header_size + variable_size; ++ let auth_space = match args.authority_type { ++ 0 => header_size + 32, // Ed25519 ++ 1 => header_size + 32 + 33 + 32, // Secp256r1 fixed ++ _ => return Err(AuthError::InvalidAuthenticationKind.into()), ++ }; + let auth_rent = rent.minimum_balance(auth_space); + + // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) +@@ -228,18 +244,50 @@ pub fn process( + wallet: *wallet_pda.key(), + }; + +- // safe write ++ // safe write of header + let header_bytes = unsafe { + std::slice::from_raw_parts( + &header as *const AuthorityAccountHeader as *const u8, +- std::mem::size_of::(), ++ header_size, + ) + }; +- auth_account_data[0..std::mem::size_of::()] +- .copy_from_slice(header_bytes); ++ auth_account_data[0..header_size].copy_from_slice(header_bytes); + +- let variable_target = &mut auth_account_data[header_size..]; +- variable_target.copy_from_slice(full_auth_data); ++ // Write variable data ++ match args.authority_type { ++ 0 => { ++ // Ed25519: pubkey(32) — full_auth_data is exactly 32 bytes ++ auth_account_data[header_size..header_size + 32] ++ .copy_from_slice(&full_auth_data[..32]); ++ } ++ 1 => { ++ // Secp256r1: cred_hash(32) ∥ pubkey(33) ∥ rpIdHash(32). ++ // full_auth_data layout as parsed above: ++ // [cred_hash(32)] [pubkey(33)] [rpIdLen(1)] [rpId(N)] ++ auth_account_data[header_size..header_size + 32] ++ .copy_from_slice(&full_auth_data[..32]); ++ auth_account_data[header_size + 32..header_size + 32 + 33] ++ .copy_from_slice(&full_auth_data[32..32 + 33]); ++ // Compute rpIdHash from rpId ++ let rp_id_len = full_auth_data[32 + 33] as usize; ++ let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; ++ let rp_id_hash_offset = header_size + 32 + 33; ++ #[cfg(target_os = "solana")] ++ unsafe { ++ let _ = pinocchio::syscalls::sol_sha256( ++ [rp_id].as_ptr() as *const u8, ++ 1, ++ auth_account_data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), ++ ); ++ } ++ #[cfg(not(target_os = "solana"))] ++ { ++ let _ = rp_id; ++ auth_account_data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); ++ } ++ } ++ _ => unreachable!(), ++ } + + Ok(()) + } +diff --git a/program/src/processor/execute.rs b/program/src/processor/execute.rs +index 32175e1..4d05e50 100644 +--- a/program/src/processor/execute.rs ++++ b/program/src/processor/execute.rs +@@ -2,9 +2,14 @@ use crate::{ + auth::{ + ed25519::Ed25519Authenticator, secp256r1::Secp256r1Authenticator, traits::Authenticator, + }, +- compact::parse_compact_instructions, ++ compact::{parse_compact_instructions_ref_with_len, CompactInstructionRef}, + error::AuthError, +- state::{authority::AuthorityAccountHeader, AccountDiscriminator}, ++ processor::execute_actions::{ ++ evaluate_post_actions, evaluate_pre_actions, snapshot_token_authorities, ++ snapshot_token_balances, verify_token_authorities_unchanged, ++ }, ++ state::{authority::AuthorityAccountHeader, session::has_actions, AccountDiscriminator}, ++ utils::get_stack_height, + }; + use pinocchio::{ + account_info::AccountInfo, +@@ -16,14 +21,13 @@ use pinocchio::{ + ProgramResult, + }; + +-/// Process the Execute instruction +-/// Processes the `Execute` instruction. ++/// Process the Execute instruction. + /// + /// Executes a batch of condensed "Compact Instructions" on behalf of the wallet. + /// + /// # Logic: + /// 1. **Authentication**: Verifies that the signer is a valid `Authority` or `Session` for this wallet. +-/// 2. **Session Checks**: If authenticated via Session, enforces slot expiry. ++/// 2. **Session Checks**: If authenticated via Session, enforces slot expiry and action permissions. + /// 3. **Decompression**: Expands `CompactInstructions` (index-based references) into full Solana instructions. + /// 4. **Execution**: Invokes the Instructions via CPI, signing with the Vault PDA. + /// +@@ -72,8 +76,6 @@ pub fn process( + } + + // Read authority header +- // Safe copy header +- // Read authority data + let authority_data = unsafe { authority_pda.borrow_mut_data_unchecked() }; + + // Authenticate based on discriminator +@@ -83,12 +85,17 @@ pub fn process( + return Err(ProgramError::InvalidAccountData); + }; + +- // Parse compact instructions +- let compact_instructions = parse_compact_instructions(instruction_data)?; ++ // Parse compact instructions and get their consumed byte length. The ++ // length is used to split `instruction_data` into the compact-instructions ++ // prefix (the data_payload bound into the Secp256r1 signature) and the ++ // auth payload suffix. Tracking the parse cursor avoids re-serializing ++ // just to measure length. ++ let (compact_instructions, compact_len) = ++ parse_compact_instructions_ref_with_len(instruction_data)?; + +- // Serialize compact instructions to get their byte length +- let compact_bytes = crate::compact::serialize_compact_instructions(&compact_instructions); +- let compact_len = compact_bytes.len(); ++ // Track whether this is a session-based execution and the current slot ++ let mut is_session = false; ++ let mut session_slot: u64 = 0; + + match discriminator { + 2 => { +@@ -96,7 +103,6 @@ pub fn process( + if authority_data.len() < std::mem::size_of::() { + return Err(ProgramError::InvalidAccountData); + } +- // Use read_unaligned to safely copy potentially unaligned data into a local struct + let authority_header = unsafe { + std::ptr::read_unaligned(authority_data.as_ptr() as *const AuthorityAccountHeader) + }; +@@ -110,21 +116,22 @@ pub fn process( + } + match authority_header.authority_type { + 0 => { +- // Ed25519: Verify signer (authority_payload ignored) +- Ed25519Authenticator.authenticate(accounts, authority_data, &[], &[], &[4], program_id)?; +- }, ++ // Ed25519 ++ Ed25519Authenticator.authenticate( ++ accounts, ++ authority_data, ++ &[], ++ &[], ++ &[4], ++ program_id, ++ )?; ++ } + 1 => { + // Secp256r1 (WebAuthn) +- // Issue #11: Include accounts hash to prevent account reordering attacks +- // signed_payload is compact_instructions bytes + accounts hash for Execute + let data_payload = &instruction_data[..compact_len]; + let authority_payload = &instruction_data[compact_len..]; +- +- // Compute hash of all account pubkeys referenced by compact instructions +- // This binds the signature to the exact accounts, preventing reordering +- let accounts_hash = compute_accounts_hash(accounts, &compact_instructions)?; +- +- // Extended payload: compact_instructions + accounts_hash ++ let accounts_hash = ++ compute_accounts_hash(accounts, &compact_instructions)?; + let mut extended_payload = Vec::with_capacity(compact_len + 32); + extended_payload.extend_from_slice(data_payload); + extended_payload.extend_from_slice(&accounts_hash); +@@ -137,21 +144,30 @@ pub fn process( + &[4], + program_id, + )?; +- }, ++ } + _ => return Err(AuthError::InvalidAuthenticationKind.into()), + } +- }, ++ } + 3 => { +- // Session +- let session_data = unsafe { authority_pda.borrow_mut_data_unchecked() }; +- if session_data.len() < std::mem::size_of::() { ++ // Session — reuse the existing `authority_data` borrow; no re-borrow needed. ++ ++ // L5: anti-CPI guard, mirroring the Secp256r1 authenticator check. ++ // A session-authenticated Execute is only valid as a top-level instruction ++ // (stack_height == 1). Rejecting CPI entry prevents any future bugs where ++ // a wrapper program could chain through Execute with forged account context. ++ if get_stack_height() > 1 { ++ return Err(AuthError::PermissionDenied.into()); ++ } ++ ++ if authority_data.len() ++ < std::mem::size_of::() ++ { + return Err(ProgramError::InvalidAccountData); + } + +- // Use read_unaligned to safely load SessionAccount + let session = unsafe { + std::ptr::read_unaligned( +- session_data.as_ptr() as *const crate::state::session::SessionAccount ++ authority_data.as_ptr() as *const crate::state::session::SessionAccount, + ) + }; + +@@ -179,7 +195,20 @@ pub fn process( + if !signer_matched { + return Err(ProgramError::MissingRequiredSignature); + } +- }, ++ ++ // Pre-CPI action checks (program whitelist/blacklist) ++ if has_actions(authority_data) { ++ evaluate_pre_actions( ++ authority_data, ++ &compact_instructions, ++ accounts, ++ current_slot, ++ )?; ++ } ++ ++ is_session = true; ++ session_slot = current_slot; ++ } + _ => return Err(ProgramError::InvalidAccountData), + } + +@@ -188,118 +217,187 @@ pub fn process( + find_program_address(&[b"vault", wallet_pda.key().as_ref()], program_id); + + // Verify vault PDA. +- // CRITICAL: Ensure we are signing with the correct Vault derived from this Wallet. + if vault_pda.key() != &vault_key { + return Err(ProgramError::InvalidSeeds); + } + ++ // Snapshot balances before CPI (for session action enforcement) ++ let vault_lamports_before = if is_session { vault_pda.lamports() } else { 0 }; ++ let token_snapshots_before = if is_session { ++ // Reuse the existing `authority_data` borrow — no additional borrow of authority_pda. ++ snapshot_token_balances(authority_data, accounts, vault_pda.key())? ++ } else { ++ Vec::new() ++ }; ++ ++ // ── Session invariants (defense against System::Assign / SetAuthority escapes) ── ++ // A session that whitelists System Program (a common pattern for SOL transfers) ++ // could otherwise craft `System::Assign(vault, attacker)` — the lamport-based ++ // limits see no outflow, but ownership of the vault silently transfers to the ++ // attacker, who then drains it in a follow-up tx. Same class of attack via ++ // SPL Token's `SetAuthority` / `Approve` on vault-owned token accounts. ++ // ++ // Snapshot the vault's metadata + every listed-mint vault-owned token account's ++ // authority fields BEFORE the CPI loop; verify unchanged AFTER. ++ let session_has_actions = is_session && has_actions(authority_data); ++ let vault_owner_before = if session_has_actions { ++ Some(*vault_pda.owner()) ++ } else { ++ None ++ }; ++ let vault_data_len_before = if session_has_actions { ++ Some(unsafe { vault_pda.borrow_data_unchecked().len() }) ++ } else { ++ None ++ }; ++ let token_authority_snapshots = if session_has_actions { ++ snapshot_token_authorities(authority_data, accounts, vault_pda.key())? ++ } else { ++ Vec::new() ++ }; ++ ++ // Track gross SOL outflow across all CPIs (for SolMaxPerTx check) ++ let mut vault_lamports_gross_out: u64 = 0; ++ let mut prev_vault_lamports = vault_lamports_before; ++ ++ // Reuse the same Vecs across all inner CPIs — allocated once, cleared + ++ // repushed each iteration. Saves 2 Vec::with_capacity allocations per ++ // inner instruction vs. .collect()ing fresh Vecs each time. ++ const MAX_INNER_ACCOUNTS: usize = 32; ++ let mut account_metas: Vec = Vec::with_capacity(MAX_INNER_ACCOUNTS); ++ let mut cpi_accounts: Vec = Vec::with_capacity(MAX_INNER_ACCOUNTS); ++ ++ // PDA signer seeds (constant across the loop) ++ let vault_bump_arr = [vault_bump]; ++ let seeds = [ ++ Seed::from(b"vault"), ++ Seed::from(wallet_pda.key().as_ref()), ++ Seed::from(&vault_bump_arr), ++ ]; ++ + // Execute each compact instruction + for compact_ix in &compact_instructions { + let decompressed = compact_ix.decompress(accounts)?; + +- // Build AccountMeta array for instruction +- let account_metas: Vec = decompressed +- .accounts +- .iter() +- .map(|acc| AccountMeta { +- pubkey: acc.key(), +- is_signer: acc.is_signer() || acc.key() == vault_pda.key(), +- is_writable: acc.is_writable(), +- }) +- .collect(); +- + // Prevent self-reentrancy (Issue #10) +- // Reject CPI calls back into this program to avoid unexpected state mutations + if decompressed.program_id.as_ref() == program_id.as_ref() { + return Err(AuthError::SelfReentrancyNotAllowed.into()); + } + +- // Create instruction ++ account_metas.clear(); ++ cpi_accounts.clear(); ++ for &acc in &decompressed.accounts { ++ account_metas.push(AccountMeta { ++ pubkey: acc.key(), ++ is_signer: acc.is_signer() || acc.key() == vault_pda.key(), ++ is_writable: acc.is_writable(), ++ }); ++ cpi_accounts.push(Account::from(acc)); ++ } ++ + let ix = Instruction { + program_id: decompressed.program_id, + accounts: &account_metas, +- data: &decompressed.data, ++ data: decompressed.data, + }; + +- // Create seeds for vault signing (pinocchio style) +- let vault_bump_arr = [vault_bump]; +- let seeds = [ +- Seed::from(b"vault"), +- Seed::from(wallet_pda.key().as_ref()), +- Seed::from(&vault_bump_arr), +- ]; + let signer: Signer = (&seeds).into(); + +- // Convert AccountInfo to Account for invoke_signed_unchecked +- let cpi_accounts: Vec = decompressed +- .accounts +- .iter() +- .map(|acc| Account::from(*acc)) +- .collect(); +- +- // Invoke with vault as signer +- // Use unchecked invocation to support dynamic account list (slice) + unsafe { + invoke_signed_unchecked(&ix, &cpi_accounts, &[signer]); + } ++ ++ // Track gross SOL outflow per CPI (used for SolMaxPerTx — not net balance diff). ++ if is_session { ++ let post = vault_pda.lamports(); ++ if prev_vault_lamports > post { ++ vault_lamports_gross_out = vault_lamports_gross_out ++ .saturating_add(prev_vault_lamports - post); ++ } ++ prev_vault_lamports = post; ++ } ++ } ++ ++ // ── Post-CPI session invariants ──────────────────────────────────── ++ // Verify vault's ownership and data layout were not tampered with. Any ++ // change (System::Assign, Allocate, AllocateWithSeed, AssignWithSeed) is ++ // rejected. This complements the balance-based limits below. ++ if let Some(owner_before) = vault_owner_before { ++ if *vault_pda.owner() != owner_before { ++ return Err(AuthError::SessionVaultOwnerChanged.into()); ++ } ++ } ++ if let Some(len_before) = vault_data_len_before { ++ let len_after = unsafe { vault_pda.borrow_data_unchecked().len() }; ++ if len_after != len_before { ++ return Err(AuthError::SessionVaultDataLenChanged.into()); ++ } ++ } ++ // Verify no SetAuthority / Approve on listed-mint vault-owned token accounts. ++ verify_token_authorities_unchanged(&token_authority_snapshots, accounts)?; ++ ++ // Post-CPI action checks (spending limits) ++ // Reuse the existing `authority_data` borrow — no additional borrow of authority_pda. ++ if session_has_actions { ++ evaluate_post_actions( ++ authority_data, ++ accounts, ++ vault_pda.key(), ++ vault_lamports_before, ++ vault_pda.lamports(), ++ vault_lamports_gross_out, ++ &token_snapshots_before, ++ session_slot, ++ )?; + } + + Ok(()) + } + +-/// Compute SHA256 hash of all account pubkeys referenced by compact instructions (Issue #11) +-/// +-/// This binds the signature to the exact accounts in their exact order, +-/// preventing account reordering attacks where an attacker could swap +-/// recipient addresses while keeping the signature valid. +-/// +-/// # Arguments +-/// * `accounts` - Slice of all account infos in the transaction +-/// * `compact_instructions` - Parsed compact instructions containing account indices ++/// Compute SHA256 hash of all account pubkeys referenced by compact instructions (Issue #11). + /// +-/// # Returns +-/// * 32-byte SHA256 hash of all referenced pubkeys ++/// Optimisation: pass each 32-byte pubkey as a separate slice to sol_sha256 ++/// instead of concatenating them into an owned Vec first. sol_sha256 accepts ++/// an array of slices natively, so the concat step was pure overhead. + fn compute_accounts_hash( + accounts: &[AccountInfo], +- compact_instructions: &[crate::compact::CompactInstruction], ++ compact_instructions: &[CompactInstructionRef<'_>], + ) -> Result<[u8; 32], ProgramError> { +- // Collect all account pubkeys in order of reference +- let mut pubkeys_data = Vec::new(); ++ // Collect slice references (16 bytes each) instead of copying 32-byte pubkeys. ++ // With MAX_COMPACT_INSTRUCTIONS = 16 and a reasonable per-ix account count, ++ // this fits comfortably on the BPF heap. ++ let mut refs: Vec<&[u8]> = Vec::with_capacity(compact_instructions.len() * 4); + + for ix in compact_instructions { +- // Include program_id + let program_idx = ix.program_id_index as usize; + if program_idx >= accounts.len() { + return Err(ProgramError::InvalidInstructionData); + } +- pubkeys_data.extend_from_slice(accounts[program_idx].key().as_ref()); ++ refs.push(accounts[program_idx].key().as_ref()); + +- // Include all account pubkeys +- for &acc_idx in &ix.accounts { ++ for &acc_idx in ix.accounts { + let idx = acc_idx as usize; + if idx >= accounts.len() { + return Err(ProgramError::InvalidInstructionData); + } +- pubkeys_data.extend_from_slice(accounts[idx].key().as_ref()); ++ refs.push(accounts[idx].key().as_ref()); + } + } + +- // Compute SHA256 hash + #[allow(unused_assignments)] + let mut hash = [0u8; 32]; + #[cfg(target_os = "solana")] + unsafe { + pinocchio::syscalls::sol_sha256( +- [pubkeys_data.as_slice()].as_ptr() as *const u8, +- 1, ++ refs.as_ptr() as *const u8, ++ refs.len() as u64, + hash.as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { +- // For tests, use a dummy hash + hash = [0xAA; 32]; +- let _ = pubkeys_data; // suppress warning ++ let _ = refs; + } + + Ok(hash) +diff --git a/program/src/processor/execute_actions.rs b/program/src/processor/execute_actions.rs +new file mode 100644 +index 0000000..bdf5b05 +--- /dev/null ++++ b/program/src/processor/execute_actions.rs +@@ -0,0 +1,1644 @@ ++//! Session action evaluation for the Execute instruction. ++//! ++//! Provides pre-CPI and post-CPI checks for session-based execution. ++//! Pre-CPI: program whitelist/blacklist enforcement. ++//! Post-CPI: spending limit enforcement with balance diffing. ++//! ++//! Security model (learned from Swig wallet): ++//! - Saturating arithmetic throughout to prevent overflow/underflow ++//! - Balance increases (vault gains) are ignored, only outflows count ++//! - Recurring limit windows align to slot boundaries ++//! - Recurring limits validate single-tx doesn't exceed full window limit ++//! - State mutations only happen after all checks pass ++//! - Zero spending transactions pass through without triggering limits ++ ++use pinocchio::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey}; ++ ++use crate::{ ++ compact::CompactInstructionRef, ++ error::AuthError, ++ state::{ ++ action::{parse_actions, read_u64, write_u64, ActionType, ActionView}, ++ session::{has_actions, SESSION_HEADER_SIZE}, ++ }, ++}; ++ ++// ─── Token Account Layout (SPL Token) ──────────────────────────────── ++// mint: bytes 0..32 ++// owner: bytes 32..64 (the authority for Transfer/Burn — changed by SetAuthority(AccountOwner)) ++// amount: bytes 64..72 ++// delegate_coi: bytes 72..108 (COption — changed by Approve/Revoke) ++// close_authority: bytes 129..165 (COption — changed by SetAuthority(CloseAccount)) ++ ++const TOKEN_MINT_OFFSET: usize = 0; ++const TOKEN_OWNER_OFFSET: usize = 32; ++const TOKEN_AMOUNT_OFFSET: usize = 64; ++const TOKEN_DELEGATE_OFFSET: usize = 72; ++const TOKEN_CLOSE_AUTHORITY_OFFSET: usize = 129; ++const TOKEN_ACCOUNT_MIN_SIZE: usize = 165; ++ ++/// A snapshot of a token account balance for a specific mint. ++pub struct TokenSnapshot { ++ pub mint: [u8; 32], ++ pub amount: u64, ++} ++ ++/// Per-token-account snapshot of authority-related fields, captured BEFORE the ++/// CPI loop in a session+actions execute. ++/// ++/// Detects SetAuthority attacks (changing owner/close_authority to attacker) and ++/// Approve-delegation attacks (granting delegate to attacker who drains outside ++/// the session). All three fields are frozen for vault-owned token accounts on ++/// listed mints while a session is executing. ++pub struct TokenAuthoritySnapshot { ++ /// The token account address (so we can re-find it post-CPI). ++ pub account_key: [u8; 32], ++ /// owner field bytes [32..64] ++ pub owner: [u8; 32], ++ /// delegate COption bytes [72..108] ++ pub delegate: [u8; 36], ++ /// close_authority COption bytes [129..165] ++ pub close_authority: [u8; 36], ++} ++ ++/// Evaluate pre-CPI actions (program whitelist/blacklist). ++/// ++/// Call this BEFORE executing compact instructions. ++/// Returns early with Ok(()) if no actions exist. ++pub fn evaluate_pre_actions( ++ session_data: &[u8], ++ compact_instructions: &[CompactInstructionRef<'_>], ++ accounts: &[AccountInfo], ++ current_slot: u64, ++) -> Result<(), ProgramError> { ++ if !has_actions(session_data) { ++ return Ok(()); ++ } ++ ++ let actions_buf = &session_data[SESSION_HEADER_SIZE..]; ++ let actions = parse_actions(actions_buf)?; ++ ++ // Collect whitelist/blacklist program IDs. ++ // Expired whitelist actions are intentionally NOT added to `whitelisted`, but they still set ++ // `has_any_whitelist_action = true`. This means if a whitelist existed but has now expired, ++ // NO program is permitted — treating an expired whitelist as a hard deny rather than open ++ // access. An expired blacklist entry, however, is silently dropped (the ban has lifted). ++ let mut whitelisted: Vec<[u8; 32]> = Vec::new(); ++ let mut blacklisted: Vec<[u8; 32]> = Vec::new(); ++ let mut has_any_whitelist_action = false; ++ ++ for action in &actions { ++ match action.action_type { ++ ActionType::ProgramWhitelist => { ++ has_any_whitelist_action = true; ++ if !is_expired(action, current_slot) { ++ let mut prog_id = [0u8; 32]; ++ prog_id.copy_from_slice( ++ &actions_buf[action.data_offset..action.data_offset + 32], ++ ); ++ whitelisted.push(prog_id); ++ } ++ } ++ ActionType::ProgramBlacklist => { ++ if !is_expired(action, current_slot) { ++ let mut prog_id = [0u8; 32]; ++ prog_id.copy_from_slice( ++ &actions_buf[action.data_offset..action.data_offset + 32], ++ ); ++ blacklisted.push(prog_id); ++ } ++ } ++ _ => {} ++ } ++ } ++ ++ // Enforce program restrictions on each instruction ++ for ix in compact_instructions { ++ let prog_idx = ix.program_id_index as usize; ++ if prog_idx >= accounts.len() { ++ return Err(ProgramError::InvalidInstructionData); ++ } ++ let target_program = accounts[prog_idx].key(); ++ ++ // Whitelist: if any whitelist action EVER existed (even expired), program must be in the ++ // active set. An expired whitelist = deny all programs. ++ if has_any_whitelist_action && !whitelisted.iter().any(|p| p == target_program.as_ref()) { ++ return Err(AuthError::ActionProgramNotWhitelisted.into()); ++ } ++ ++ // Blacklist: program must NOT be in the active set (expired entries already dropped above). ++ if blacklisted.iter().any(|p| p == target_program.as_ref()) { ++ return Err(AuthError::ActionProgramBlacklisted.into()); ++ } ++ } ++ ++ Ok(()) ++} ++ ++/// Snapshot token balances for mints referenced in token actions. ++pub fn snapshot_token_balances( ++ session_data: &[u8], ++ accounts: &[AccountInfo], ++ vault_key: &Pubkey, ++) -> Result, ProgramError> { ++ if !has_actions(session_data) { ++ return Ok(Vec::new()); ++ } ++ ++ let actions_buf = &session_data[SESSION_HEADER_SIZE..]; ++ let actions = parse_actions(actions_buf)?; ++ ++ let mut mints: Vec<[u8; 32]> = Vec::new(); ++ for action in &actions { ++ match action.action_type { ++ ActionType::TokenLimit ++ | ActionType::TokenRecurringLimit ++ | ActionType::TokenMaxPerTx => { ++ let mut mint = [0u8; 32]; ++ mint.copy_from_slice(&actions_buf[action.data_offset..action.data_offset + 32]); ++ if !mints.iter().any(|m| m == &mint) { ++ mints.push(mint); ++ } ++ } ++ _ => {} ++ } ++ } ++ ++ if mints.is_empty() { ++ return Ok(Vec::new()); ++ } ++ ++ let mut snapshots = Vec::new(); ++ for mint in &mints { ++ if let Some(amount) = find_token_balance(accounts, vault_key, mint) { ++ snapshots.push(TokenSnapshot { ++ mint: *mint, ++ amount, ++ }); ++ } ++ } ++ ++ Ok(snapshots) ++} ++ ++/// Snapshot per-token-account authority fields for every vault-owned token ++/// account whose mint appears in a token action. ++/// ++/// Paired with `verify_token_authorities_unchanged` post-CPI. Together they ++/// prevent `SetAuthority` and `Approve`-style escapes where the session key ++/// would otherwise reassign control of vault-owned token accounts without ++/// moving any lamports (so the balance-based limits would miss it). ++pub fn snapshot_token_authorities( ++ session_data: &[u8], ++ accounts: &[AccountInfo], ++ vault_key: &Pubkey, ++) -> Result, ProgramError> { ++ if !has_actions(session_data) { ++ return Ok(Vec::new()); ++ } ++ ++ let actions_buf = &session_data[SESSION_HEADER_SIZE..]; ++ let actions = parse_actions(actions_buf)?; ++ ++ // Collect listed mints (same logic as snapshot_token_balances). ++ let mut mints: Vec<[u8; 32]> = Vec::new(); ++ for action in &actions { ++ match action.action_type { ++ ActionType::TokenLimit ++ | ActionType::TokenRecurringLimit ++ | ActionType::TokenMaxPerTx => { ++ let mut mint = [0u8; 32]; ++ mint.copy_from_slice(&actions_buf[action.data_offset..action.data_offset + 32]); ++ if !mints.iter().any(|m| m == &mint) { ++ mints.push(mint); ++ } ++ } ++ _ => {} ++ } ++ } ++ if mints.is_empty() { ++ return Ok(Vec::new()); ++ } ++ ++ // Scan all SPL-Token-owned accounts; snapshot each vault-owned one whose ++ // mint is listed in the session actions. ++ let mut out = Vec::new(); ++ for acc in accounts { ++ let owner = acc.owner(); ++ if owner.as_ref() != &SPL_TOKEN_PROGRAM_ID ++ && owner.as_ref() != &SPL_TOKEN_2022_PROGRAM_ID ++ { ++ continue; ++ } ++ let data = unsafe { acc.borrow_data_unchecked() }; ++ if data.len() < TOKEN_ACCOUNT_MIN_SIZE { ++ continue; ++ } ++ // vault must currently own it ++ if &data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32] != vault_key.as_ref() { ++ continue; ++ } ++ // mint must be listed ++ let mut mint = [0u8; 32]; ++ mint.copy_from_slice(&data[TOKEN_MINT_OFFSET..TOKEN_MINT_OFFSET + 32]); ++ if !mints.iter().any(|m| m == &mint) { ++ continue; ++ } ++ ++ let mut owner_bytes = [0u8; 32]; ++ owner_bytes.copy_from_slice(&data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32]); ++ let mut delegate = [0u8; 36]; ++ delegate.copy_from_slice(&data[TOKEN_DELEGATE_OFFSET..TOKEN_DELEGATE_OFFSET + 36]); ++ let mut close_authority = [0u8; 36]; ++ close_authority.copy_from_slice( ++ &data[TOKEN_CLOSE_AUTHORITY_OFFSET..TOKEN_CLOSE_AUTHORITY_OFFSET + 36], ++ ); ++ ++ let mut account_key = [0u8; 32]; ++ account_key.copy_from_slice(acc.key().as_ref()); ++ ++ out.push(TokenAuthoritySnapshot { ++ account_key, ++ owner: owner_bytes, ++ delegate, ++ close_authority, ++ }); ++ } ++ ++ Ok(out) ++} ++ ++/// Verify that every snapshotted token account still has the same owner, ++/// delegate, and close_authority fields. Returns an error if any field has ++/// changed. ++pub fn verify_token_authorities_unchanged( ++ snapshots: &[TokenAuthoritySnapshot], ++ accounts: &[AccountInfo], ++) -> Result<(), ProgramError> { ++ for snap in snapshots { ++ // Find the account by key in the tx accounts list. ++ let acc = match accounts.iter().find(|a| a.key().as_ref() == snap.account_key) { ++ Some(a) => a, ++ // If the account disappeared (e.g. CloseAccount closed it), that's also a ++ // mutation we should reject. CloseAccount sends rent lamports to an ++ // attacker-chosen destination without touching the token balance. ++ None => return Err(AuthError::SessionTokenAuthorityChanged.into()), ++ }; ++ ++ // Must still be owned by SPL Token (not re-assigned to another program) ++ let owner = acc.owner(); ++ if owner.as_ref() != &SPL_TOKEN_PROGRAM_ID ++ && owner.as_ref() != &SPL_TOKEN_2022_PROGRAM_ID ++ { ++ return Err(AuthError::SessionTokenAuthorityChanged.into()); ++ } ++ ++ let data = unsafe { acc.borrow_data_unchecked() }; ++ if data.len() < TOKEN_ACCOUNT_MIN_SIZE { ++ return Err(AuthError::SessionTokenAuthorityChanged.into()); ++ } ++ ++ if &data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32] != snap.owner { ++ return Err(AuthError::SessionTokenAuthorityChanged.into()); ++ } ++ if &data[TOKEN_DELEGATE_OFFSET..TOKEN_DELEGATE_OFFSET + 36] != snap.delegate { ++ return Err(AuthError::SessionTokenAuthorityChanged.into()); ++ } ++ if &data[TOKEN_CLOSE_AUTHORITY_OFFSET..TOKEN_CLOSE_AUTHORITY_OFFSET + 36] ++ != snap.close_authority ++ { ++ return Err(AuthError::SessionTokenAuthorityChanged.into()); ++ } ++ } ++ Ok(()) ++} ++ ++/// Evaluate post-CPI actions (spending limits). ++/// ++/// `vault_lamports_gross_out` is the sum of all per-CPI outflows from the vault, used for ++/// `SolMaxPerTx` (which must block even DeFi round-trips that appear net-zero). ++/// `vault_lamports_before`/`after` net diff is used for the cumulative limits (SolLimit, ++/// SolRecurringLimit), where net accounting is conservative and appropriate. ++/// ++/// Security: This function first computes all spending deltas and validates ++/// ALL limits before writing any state. This ensures no partial state mutation ++/// if a later check fails. ++pub fn evaluate_post_actions( ++ session_data: &mut [u8], ++ accounts: &[AccountInfo], ++ vault_key: &Pubkey, ++ vault_lamports_before: u64, ++ vault_lamports_after: u64, ++ vault_lamports_gross_out: u64, ++ token_snapshots_before: &[TokenSnapshot], ++ current_slot: u64, ++) -> Result<(), ProgramError> { ++ if !has_actions(session_data) { ++ return Ok(()); ++ } ++ ++ // Only count outflows. If vault gained lamports, sol_spent = 0. ++ // This matches Swig's pattern: balance increases are tracked but not counted against limits. ++ let sol_spent = vault_lamports_before.saturating_sub(vault_lamports_after); ++ ++ // If nothing was spent, skip all checks (no state mutation needed for SOL). ++ // Token checks still need to run. ++ ++ let actions_buf_readonly = &session_data[SESSION_HEADER_SIZE..]; ++ let actions = parse_actions(actions_buf_readonly)?; ++ ++ // ── Phase 1: Validate all SOL limits (read-only check) ────────── ++ // Expired spending-limit actions are treated as fully exhausted / "0 remaining": ++ // if any SOL was spent and a limit action has expired, the tx is rejected. ++ // This prevents a session with expired limits from becoming unrestricted. ++ for action in &actions { ++ let action_expired = is_expired(action, current_slot); ++ let abs_data_offset = SESSION_HEADER_SIZE + action.data_offset; ++ ++ match action.action_type { ++ ActionType::SolMaxPerTx => { ++ // Use gross outflow so DeFi round-trips that return most lamports cannot bypass ++ // a per-tx cap (the net diff would be near-zero but gross could be large). ++ if vault_lamports_gross_out > 0 { ++ if action_expired { ++ return Err(AuthError::ActionSolMaxPerTxExceeded.into()); ++ } ++ let max = read_u64(&session_data[abs_data_offset..], 0); ++ if vault_lamports_gross_out > max { ++ return Err(AuthError::ActionSolMaxPerTxExceeded.into()); ++ } ++ } ++ } ++ ActionType::SolLimit => { ++ if sol_spent > 0 { ++ if action_expired { ++ return Err(AuthError::ActionSolLimitExceeded.into()); ++ } ++ let remaining = read_u64(&session_data[abs_data_offset..], 0); ++ if sol_spent > remaining { ++ return Err(AuthError::ActionSolLimitExceeded.into()); ++ } ++ } ++ } ++ ActionType::SolRecurringLimit => { ++ if sol_spent > 0 { ++ if action_expired { ++ return Err(AuthError::ActionSolRecurringLimitExceeded.into()); ++ } ++ let limit = read_u64(&session_data[abs_data_offset..], 0); ++ let spent = read_u64(&session_data[abs_data_offset..], 8); ++ let window = read_u64(&session_data[abs_data_offset..], 16); ++ let last_reset = read_u64(&session_data[abs_data_offset..], 24); ++ ++ let effective_spent = if current_slot.saturating_sub(last_reset) > window { ++ // Window expired — reset. But single tx can't exceed full limit. ++ if sol_spent > limit { ++ return Err(AuthError::ActionSolRecurringLimitExceeded.into()); ++ } ++ 0u64 ++ } else { ++ spent ++ }; ++ ++ // Use saturating_add to prevent overflow ++ if effective_spent.saturating_add(sol_spent) > limit { ++ return Err(AuthError::ActionSolRecurringLimitExceeded.into()); ++ } ++ } ++ } ++ _ => {} ++ } ++ } ++ ++ // ── Phase 1b: Validate all token limits (read-only check) ─────── ++ // Same policy as SOL limits: expired = treat as fully exhausted. ++ for action in &actions { ++ let action_expired = is_expired(action, current_slot); ++ let abs_data_offset = SESSION_HEADER_SIZE + action.data_offset; ++ ++ match action.action_type { ++ ActionType::TokenMaxPerTx | ActionType::TokenLimit | ActionType::TokenRecurringLimit => { ++ let mut mint = [0u8; 32]; ++ mint.copy_from_slice(&session_data[abs_data_offset..abs_data_offset + 32]); ++ ++ let before_amount = token_snapshots_before ++ .iter() ++ .find(|s| s.mint == mint) ++ .map(|s| s.amount) ++ .unwrap_or(0); ++ ++ let after_amount = find_token_balance(accounts, vault_key, &mint).unwrap_or(0); ++ ++ // Only count outflows ++ let token_spent = before_amount.saturating_sub(after_amount); ++ ++ if token_spent > 0 { ++ if action_expired { ++ // Treat expired token limit as fully exhausted — deny any spend. ++ return match action.action_type { ++ ActionType::TokenMaxPerTx => Err(AuthError::ActionTokenMaxPerTxExceeded.into()), ++ ActionType::TokenLimit => Err(AuthError::ActionTokenLimitExceeded.into()), ++ _ => Err(AuthError::ActionTokenRecurringLimitExceeded.into()), ++ }; ++ } ++ match action.action_type { ++ ActionType::TokenMaxPerTx => { ++ let max = read_u64(&session_data[abs_data_offset..], 32); ++ if token_spent > max { ++ return Err(AuthError::ActionTokenMaxPerTxExceeded.into()); ++ } ++ } ++ ActionType::TokenLimit => { ++ let remaining = read_u64(&session_data[abs_data_offset..], 32); ++ if token_spent > remaining { ++ return Err(AuthError::ActionTokenLimitExceeded.into()); ++ } ++ } ++ ActionType::TokenRecurringLimit => { ++ let limit = read_u64(&session_data[abs_data_offset..], 32); ++ let spent = read_u64(&session_data[abs_data_offset..], 40); ++ let window = read_u64(&session_data[abs_data_offset..], 48); ++ let last_reset = read_u64(&session_data[abs_data_offset..], 56); ++ ++ let effective_spent = ++ if current_slot.saturating_sub(last_reset) > window { ++ if token_spent > limit { ++ return Err( ++ AuthError::ActionTokenRecurringLimitExceeded.into() ++ ); ++ } ++ 0u64 ++ } else { ++ spent ++ }; ++ ++ if effective_spent.saturating_add(token_spent) > limit { ++ return Err(AuthError::ActionTokenRecurringLimitExceeded.into()); ++ } ++ } ++ _ => {} ++ } ++ } ++ } ++ _ => {} ++ } ++ } ++ ++ // ── Phase 2: All checks passed. Now write state mutations. ────── ++ // Re-parse using a slice reference — no allocation needed, same bytes, same offsets. ++ let actions = parse_actions(&session_data[SESSION_HEADER_SIZE..])?; ++ ++ for action in &actions { ++ if is_expired(action, current_slot) { ++ continue; ++ } ++ ++ let abs_data_offset = SESSION_HEADER_SIZE + action.data_offset; ++ ++ match action.action_type { ++ ActionType::SolLimit => { ++ if sol_spent > 0 { ++ let remaining = read_u64(&session_data[abs_data_offset..], 0); ++ write_u64( ++ &mut session_data[abs_data_offset..], ++ 0, ++ remaining.saturating_sub(sol_spent), ++ ); ++ } ++ } ++ ActionType::SolRecurringLimit => { ++ if sol_spent > 0 { ++ let _limit = read_u64(&session_data[abs_data_offset..], 0); ++ let spent = read_u64(&session_data[abs_data_offset..], 8); ++ let window = read_u64(&session_data[abs_data_offset..], 16); ++ let last_reset = read_u64(&session_data[abs_data_offset..], 24); ++ ++ let (new_spent, new_last_reset) = ++ if current_slot.saturating_sub(last_reset) > window { ++ let aligned = (current_slot / window) * window; ++ (sol_spent, aligned) ++ } else { ++ (spent.saturating_add(sol_spent), last_reset) ++ }; ++ ++ write_u64(&mut session_data[abs_data_offset..], 8, new_spent); ++ write_u64(&mut session_data[abs_data_offset..], 24, new_last_reset); ++ } ++ } ++ ActionType::TokenLimit => { ++ let mut mint = [0u8; 32]; ++ mint.copy_from_slice(&session_data[abs_data_offset..abs_data_offset + 32]); ++ let before = token_snapshots_before ++ .iter() ++ .find(|s| s.mint == mint) ++ .map(|s| s.amount) ++ .unwrap_or(0); ++ let after = find_token_balance(accounts, vault_key, &mint).unwrap_or(0); ++ let token_spent = before.saturating_sub(after); ++ ++ if token_spent > 0 { ++ let remaining = read_u64(&session_data[abs_data_offset..], 32); ++ write_u64( ++ &mut session_data[abs_data_offset..], ++ 32, ++ remaining.saturating_sub(token_spent), ++ ); ++ } ++ } ++ ActionType::TokenRecurringLimit => { ++ let mut mint = [0u8; 32]; ++ mint.copy_from_slice(&session_data[abs_data_offset..abs_data_offset + 32]); ++ let before = token_snapshots_before ++ .iter() ++ .find(|s| s.mint == mint) ++ .map(|s| s.amount) ++ .unwrap_or(0); ++ let after = find_token_balance(accounts, vault_key, &mint).unwrap_or(0); ++ let token_spent = before.saturating_sub(after); ++ ++ if token_spent > 0 { ++ let spent = read_u64(&session_data[abs_data_offset..], 40); ++ let window = read_u64(&session_data[abs_data_offset..], 48); ++ let last_reset = read_u64(&session_data[abs_data_offset..], 56); ++ ++ let (new_spent, new_last_reset) = ++ if current_slot.saturating_sub(last_reset) > window { ++ let aligned = (current_slot / window) * window; ++ (token_spent, aligned) ++ } else { ++ (spent.saturating_add(token_spent), last_reset) ++ }; ++ ++ write_u64(&mut session_data[abs_data_offset..], 40, new_spent); ++ write_u64(&mut session_data[abs_data_offset..], 56, new_last_reset); ++ } ++ } ++ _ => {} // SolMaxPerTx, TokenMaxPerTx, whitelist/blacklist have no mutable state ++ } ++ } ++ ++ Ok(()) ++} ++ ++// ─── Helpers ────────────────────────────────────────────────────────── ++ ++/// Check if an action has expired. ++#[inline] ++fn is_expired(action: &ActionView, current_slot: u64) -> bool { ++ action.expires_at != 0 && current_slot > action.expires_at ++} ++ ++/// SPL Token program ID ++const SPL_TOKEN_PROGRAM_ID: [u8; 32] = [ ++ 6, 221, 246, 225, 215, 101, 161, 147, 217, 203, 225, 70, 206, 235, 121, 172, 28, 180, 133, ++ 237, 95, 91, 55, 145, 58, 140, 245, 133, 126, 255, 0, 169, ++]; ++ ++/// SPL Token-2022 program ID ++const SPL_TOKEN_2022_PROGRAM_ID: [u8; 32] = [ ++ 6, 221, 246, 225, 238, 117, 143, 222, 170, 164, 12, 4, 223, 116, 174, 240, 70, 137, 163, 89, ++ 77, 149, 128, 12, 61, 73, 196, 253, 210, 164, 82, 159, ++]; ++ ++/// Find the total token balance across ALL token accounts for a given mint owned by the vault. ++/// ++/// Security: Sums every matching account rather than returning the first match. ++/// Returning only the first match allowed an attacker to place a 0-balance dummy ++/// token account (owned by vault, same mint) before the real account in the ++/// accounts list, causing both the pre-CPI snapshot and post-CPI check to read ++/// the dummy account (balance always 0) and bypass all token spending limits. ++/// ++/// Verifies each account is owned by SPL Token or Token-2022 to prevent fake ++/// accounts with fabricated mint/owner fields. ++fn find_token_balance( ++ accounts: &[AccountInfo], ++ vault_key: &Pubkey, ++ mint: &[u8; 32], ++) -> Option { ++ let mut total: u64 = 0; ++ let mut found = false; ++ ++ for acc in accounts { ++ // CRITICAL: Verify account is owned by SPL Token or Token-2022 program. ++ let owner = acc.owner(); ++ if owner.as_ref() != &SPL_TOKEN_PROGRAM_ID && owner.as_ref() != &SPL_TOKEN_2022_PROGRAM_ID ++ { ++ continue; ++ } ++ ++ let data = unsafe { acc.borrow_data_unchecked() }; ++ if data.len() < TOKEN_ACCOUNT_MIN_SIZE { ++ continue; ++ } ++ if &data[TOKEN_MINT_OFFSET..TOKEN_MINT_OFFSET + 32] != mint { ++ continue; ++ } ++ if &data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32] != vault_key.as_ref() { ++ continue; ++ } ++ let amount = u64::from_le_bytes( ++ match data[TOKEN_AMOUNT_OFFSET..TOKEN_AMOUNT_OFFSET + 8].try_into() { ++ Ok(b) => b, ++ Err(_) => continue, ++ }, ++ ); ++ total = total.saturating_add(amount); ++ found = true; ++ } ++ ++ if found { Some(total) } else { None } ++} ++ ++// ─── Tests ──────────────────────────────────────────────────────────── ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use crate::state::action::ACTION_HEADER_SIZE; ++ ++ fn build_action(action_type: u8, expires_at: u64, data: &[u8]) -> Vec { ++ let mut buf = Vec::new(); ++ buf.push(action_type); ++ buf.extend_from_slice(&(data.len() as u16).to_le_bytes()); ++ buf.extend_from_slice(&expires_at.to_le_bytes()); ++ buf.extend_from_slice(data); ++ buf ++ } ++ ++ fn build_session_data(actions: &[u8]) -> Vec { ++ let mut data = vec![0u8; SESSION_HEADER_SIZE]; ++ data[0] = 3; // discriminator ++ data.extend_from_slice(actions); ++ data ++ } ++ ++ /// Test helper: calls evaluate_post_actions with gross_out = before - after (single CPI). ++ fn eval_post( ++ session_data: &mut [u8], ++ accounts: &[AccountInfo], ++ vault_key: &Pubkey, ++ before: u64, ++ after: u64, ++ token_snapshots: &[TokenSnapshot], ++ slot: u64, ++ ) -> Result<(), ProgramError> { ++ let gross = before.saturating_sub(after); ++ evaluate_post_actions(session_data, accounts, vault_key, before, after, gross, token_snapshots, slot) ++ } ++ ++ fn build_sol_recurring(limit: u64, spent: u64, window: u64, last_reset: u64) -> Vec { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&limit.to_le_bytes()); ++ data.extend_from_slice(&spent.to_le_bytes()); ++ data.extend_from_slice(&window.to_le_bytes()); ++ data.extend_from_slice(&last_reset.to_le_bytes()); ++ data ++ } ++ ++ // ─── Basic functionality ────────────────────────────────────── ++ ++ #[test] ++ fn test_no_actions_passthrough() { ++ let mut session_data = vec![0u8; SESSION_HEADER_SIZE]; ++ session_data[0] = 3; ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 10_000_000, 0, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ } ++ ++ #[test] ++ fn test_zero_spending_no_state_change() { ++ let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ let original = session_data.clone(); ++ ++ // vault gained lamports (before < after) → sol_spent = 0 ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 2_000_000, // vault gained 1M ++ &[], 100, ++ ); ++ assert!(result.is_ok()); ++ // State unchanged — remaining should still be 1M ++ assert_eq!(session_data, original); ++ } ++ ++ #[test] ++ fn test_vault_balance_increase_ignored() { ++ // SolMaxPerTx of 500k, but vault GAINS lamports ++ let actions = build_action(3, 0, &500_000u64.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 5_000_000, // gained 4M ++ &[], 100, ++ ); ++ assert!(result.is_ok()); // No violation, gains are ignored ++ } ++ ++ // ─── SolLimit ───────────────────────────────────────────────── ++ ++ #[test] ++ fn test_sol_limit_exact_remaining() { ++ let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ ++ // Spend exactly the remaining amount — should succeed ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_000_000, // spent exactly 1M ++ &[], 100, ++ ); ++ assert!(result.is_ok()); ++ ++ let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; ++ let remaining = read_u64(&session_data[abs_offset..], 0); ++ assert_eq!(remaining, 0); ++ } ++ ++ #[test] ++ fn test_sol_limit_depletes_across_txs() { ++ let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ ++ // Tx 1: spend 600k ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_400_000, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ ++ let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; ++ assert_eq!(read_u64(&session_data[abs_offset..], 0), 400_000); ++ ++ // Tx 2: spend 400k (exact remaining) — OK ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_400_000, 1_000_000, &[], 101, ++ ); ++ assert!(result.is_ok()); ++ assert_eq!(read_u64(&session_data[abs_offset..], 0), 0); ++ ++ // Tx 3: spend 1 lamport — should fail (0 remaining) ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 999_999, &[], 102, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_sol_limit_single_overspend() { ++ let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ ++ // Try to spend 1M + 1 — should fail ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 999_999, // spent 1_000_001 ++ &[], 100, ++ ); ++ assert!(result.is_err()); ++ ++ // State unchanged after failed check ++ let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; ++ assert_eq!(read_u64(&session_data[abs_offset..], 0), 1_000_000); ++ } ++ ++ // ─── SolMaxPerTx ────────────────────────────────────────────── ++ ++ #[test] ++ fn test_sol_max_per_tx_exact_limit() { ++ let actions = build_action(3, 0, &500_000u64.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ ++ // Spend exactly the max — OK ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_500_000, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ ++ // Exceed by 1 — fail ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_499_999, &[], 101, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_sol_max_per_tx_repeatable() { ++ // MaxPerTx does NOT accumulate — each tx is independent ++ let actions = build_action(3, 0, &500_000u64.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ ++ for slot in 100..110 { ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_500_000, // 500k each time ++ &[], slot, ++ ); ++ assert!(result.is_ok()); ++ } ++ } ++ ++ // ─── SolRecurringLimit ──────────────────────────────────────── ++ ++ #[test] ++ fn test_sol_recurring_limit_basic() { ++ let data = build_sol_recurring(1_000_000, 0, 100, 0); ++ let actions = build_action(2, 0, &data); ++ let mut session_data = build_session_data(&actions); ++ ++ // Spend 600k at slot 50 ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_400_000, &[], 50, ++ ); ++ assert!(result.is_ok()); ++ ++ // Spend 500k more at slot 60 — total 1.1M > 1M limit ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_400_000, 900_000, &[], 60, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_sol_recurring_limit_window_reset() { ++ let data = build_sol_recurring(1_000_000, 0, 100, 0); ++ let actions = build_action(2, 0, &data); ++ let mut session_data = build_session_data(&actions); ++ ++ // Spend 900k at slot 50 ++ eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_100_000, &[], 50, ++ ).unwrap(); ++ ++ // At slot 150 (after window), 500k should work again ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_100_000, 600_000, &[], 150, ++ ); ++ assert!(result.is_ok()); ++ ++ // Verify last_reset was aligned to window boundary ++ let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; ++ let last_reset = read_u64(&session_data[abs_offset..], 24); ++ assert_eq!(last_reset, 100); // (150 / 100) * 100 = 100 ++ } ++ ++ #[test] ++ fn test_sol_recurring_single_tx_exceeds_full_limit_after_reset() { ++ let data = build_sol_recurring(1_000_000, 0, 100, 0); ++ let actions = build_action(2, 0, &data); ++ let mut session_data = build_session_data(&actions); ++ ++ // At slot 150 (fresh window), try to spend more than the full limit ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 5_000_000, 3_500_000, // 1.5M > 1M limit ++ &[], 150, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_sol_recurring_exact_limit_in_window() { ++ let data = build_sol_recurring(1_000_000, 0, 100, 0); ++ let actions = build_action(2, 0, &data); ++ let mut session_data = build_session_data(&actions); ++ ++ // Spend exactly the limit ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_000_000, &[], 50, ++ ); ++ assert!(result.is_ok()); ++ ++ // Spend 1 more in same window — fail ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 999_999, &[], 60, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_sol_recurring_overflow_protection() { ++ // spent is near u64::MAX, adding more would overflow ++ let data = build_sol_recurring(u64::MAX, u64::MAX - 100, 1000, 0); ++ let actions = build_action(2, 0, &data); ++ let mut session_data = build_session_data(&actions); ++ ++ // Spend 200 — would overflow spent + sol_spent without saturating_add ++ // But limit is u64::MAX so it should be within limit ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 999_800, // spent 200 ++ &[], 50, ++ ); ++ // saturating_add(u64::MAX - 100, 200) = u64::MAX, which == limit, so OK ++ assert!(result.is_ok()); ++ } ++ ++ // ─── Combined actions ───────────────────────────────────────── ++ ++ #[test] ++ fn test_combined_sol_limit_and_max_per_tx() { ++ let mut actions_buf = Vec::new(); ++ // SolLimit: 2M lifetime ++ actions_buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); ++ // SolMaxPerTx: 500k per tx ++ actions_buf.extend_from_slice(&build_action(3, 0, &500_000u64.to_le_bytes())); ++ ++ let mut session_data = build_session_data(&actions_buf); ++ ++ // 400k — under both limits ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 5_000_000, 4_600_000, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ ++ // 600k — under lifetime (1.6M left) but over per-tx (500k) ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 4_600_000, 4_000_000, &[], 101, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_combined_recurring_and_max_per_tx() { ++ let mut actions_buf = Vec::new(); ++ // SolRecurringLimit: 1M per 100 slots ++ actions_buf.extend_from_slice(&build_action(2, 0, &build_sol_recurring(1_000_000, 0, 100, 0))); ++ // SolMaxPerTx: 300k per tx ++ actions_buf.extend_from_slice(&build_action(3, 0, &300_000u64.to_le_bytes())); ++ ++ let mut session_data = build_session_data(&actions_buf); ++ ++ // 200k — OK ++ eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 5_000_000, 4_800_000, &[], 50, ++ ).unwrap(); ++ ++ // 200k more — OK (400k total in window, under 1M; 200k under 300k per-tx) ++ eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 4_800_000, 4_600_000, &[], 60, ++ ).unwrap(); ++ ++ // 350k — fails per-tx (350k > 300k) even though recurring has room ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 4_600_000, 4_250_000, &[], 70, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ // ─── Action expiry ──────────────────────────────────────────── ++ ++ #[test] ++ fn test_expired_action_blocks_spending() { ++ // Expired spending limits are treated as fully exhausted (not skipped). ++ let actions = build_action(3, 50, &500_000u64.to_le_bytes()); // Expires at slot 50 ++ let mut session_data = build_session_data(&actions); ++ ++ // At slot 100, action expired — any spend should FAIL ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_400_000, &[], 100, ++ ); ++ assert!(result.is_err()); ++ ++ // Zero spending is still OK even with expired action ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 2_000_000, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ } ++ ++ #[test] ++ fn test_action_active_at_expiry_slot() { ++ // Action expires at slot 50. At exactly slot 50 it should still be active. ++ // Only expired when current_slot > expires_at. ++ let actions = build_action(3, 50, &500_000u64.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ ++ // At slot 50 — still active, 600k > 500k → fail ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_400_000, &[], 50, ++ ); ++ assert!(result.is_err()); ++ ++ // At slot 51 — expired, any spend → also fail (expired = exhausted) ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_400_000, &[], 51, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_mixed_expired_and_active_actions() { ++ let mut actions_buf = Vec::new(); ++ // SolMaxPerTx: 500k, expires at slot 50 ++ actions_buf.extend_from_slice(&build_action(3, 50, &500_000u64.to_le_bytes())); ++ // SolLimit: 2M, never expires ++ actions_buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); ++ ++ let mut session_data = build_session_data(&actions_buf); ++ ++ // At slot 100: MaxPerTx expired → any spend blocked by expired MaxPerTx ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 5_000_000, 2_000_000, &[], 100, ++ ); ++ assert!(result.is_err()); ++ ++ // Even 1 lamport fails because expired MaxPerTx blocks all spending ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 5_000_000, 4_999_999, &[], 100, ++ ); ++ assert!(result.is_err()); ++ ++ // Zero spend is OK ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 5_000_000, 5_000_000, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ } ++ ++ // ─── State mutation safety ──────────────────────────────────── ++ ++ #[test] ++ fn test_failed_check_no_state_mutation() { ++ let mut actions_buf = Vec::new(); ++ // SolLimit: 2M ++ actions_buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); ++ // SolMaxPerTx: 100k (will fail) ++ actions_buf.extend_from_slice(&build_action(3, 0, &100_000u64.to_le_bytes())); ++ ++ let mut session_data = build_session_data(&actions_buf); ++ let original = session_data.clone(); ++ ++ // 500k spend — passes SolLimit but fails SolMaxPerTx ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 5_000_000, 4_500_000, &[], 100, ++ ); ++ assert!(result.is_err()); ++ ++ // Because we validate ALL checks before writing, state is unchanged ++ assert_eq!(session_data, original); ++ } ++ ++ #[test] ++ fn test_recurring_state_persists_correctly() { ++ let data = build_sol_recurring(1_000_000, 0, 100, 0); ++ let actions = build_action(2, 0, &data); ++ let mut session_data = build_session_data(&actions); ++ let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; ++ ++ // Spend 300k at slot 50 ++ eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 2_000_000, 1_700_000, &[], 50, ++ ).unwrap(); ++ ++ assert_eq!(read_u64(&session_data[abs_offset..], 8), 300_000); // spent ++ assert_eq!(read_u64(&session_data[abs_offset..], 24), 0); // last_reset (first window) ++ ++ // Spend 200k at slot 60 ++ eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_700_000, 1_500_000, &[], 60, ++ ).unwrap(); ++ ++ assert_eq!(read_u64(&session_data[abs_offset..], 8), 500_000); // cumulative ++ ++ // Window reset at slot 200 ++ eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_500_000, 1_300_000, &[], 200, ++ ).unwrap(); ++ ++ assert_eq!(read_u64(&session_data[abs_offset..], 8), 200_000); // reset + new spend ++ assert_eq!(read_u64(&session_data[abs_offset..], 24), 200); // aligned: (200/100)*100 ++ } ++ ++ // ─── Edge: zero limit ───────────────────────────────────────── ++ ++ #[test] ++ fn test_zero_sol_limit_blocks_all_spending() { ++ let actions = build_action(1, 0, &0u64.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ ++ // Even 1 lamport should fail ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 999_999, &[], 100, ++ ); ++ assert!(result.is_err()); ++ ++ // But zero spending is OK ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 1_000_000, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ } ++ ++ #[test] ++ fn test_zero_max_per_tx_blocks_all_spending() { ++ let actions = build_action(3, 0, &0u64.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 999_999, &[], 100, ++ ); ++ assert!(result.is_err()); ++ ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 1_000_000, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ } ++ ++ // ══════════════════════════════════════════════════════════════════ ++ // Token spending limit tests ++ // ══════════════════════════════════════════════════════════════════ ++ // ++ // Token tests use empty `accounts` slice so find_token_balance returns ++ // None → unwrap_or(0), meaning "all tokens drained." We provide ++ // token_snapshots_before with the initial balance to compute spending. ++ ++ fn build_token_limit(mint: &[u8; 32], remaining: u64) -> Vec { ++ let mut data = Vec::new(); ++ data.extend_from_slice(mint); // [0..32] ++ data.extend_from_slice(&remaining.to_le_bytes()); // [32..40] ++ data ++ } ++ ++ fn build_token_max_per_tx(mint: &[u8; 32], max: u64) -> Vec { ++ let mut data = Vec::new(); ++ data.extend_from_slice(mint); ++ data.extend_from_slice(&max.to_le_bytes()); ++ data ++ } ++ ++ fn build_token_recurring(mint: &[u8; 32], limit: u64, spent: u64, window: u64, last_reset: u64) -> Vec { ++ let mut data = Vec::new(); ++ data.extend_from_slice(mint); // [0..32] ++ data.extend_from_slice(&limit.to_le_bytes()); // [32..40] ++ data.extend_from_slice(&spent.to_le_bytes()); // [40..48] ++ data.extend_from_slice(&window.to_le_bytes()); // [48..56] ++ data.extend_from_slice(&last_reset.to_le_bytes()); // [56..64] ++ data ++ } ++ ++ // ── TokenLimit ─────────────────────────────────────────────────── ++ ++ #[test] ++ fn test_token_limit_within_budget() { ++ let mint = [0xAA; 32]; ++ let actions = build_action(4, 0, &build_token_limit(&mint, 1_000_000)); ++ let mut session_data = build_session_data(&actions); ++ let snapshots = vec![TokenSnapshot { mint, amount: 500_000 }]; ++ ++ // accounts=[] → after=0, token_spent=500_000, within 1M limit ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 0, 0, &snapshots, 100, ++ ); ++ assert!(result.is_ok()); ++ ++ // Verify remaining was decremented ++ let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; ++ let remaining = read_u64(&session_data[abs_offset..], 32); ++ assert_eq!(remaining, 500_000); ++ } ++ ++ #[test] ++ fn test_token_limit_exceeds_budget() { ++ let mint = [0xBB; 32]; ++ let actions = build_action(4, 0, &build_token_limit(&mint, 100_000)); ++ let mut session_data = build_session_data(&actions); ++ let snapshots = vec![TokenSnapshot { mint, amount: 200_000 }]; ++ ++ // token_spent=200k > remaining=100k → fail ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 0, 0, &snapshots, 100, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_token_limit_exact_budget() { ++ let mint = [0xCC; 32]; ++ let actions = build_action(4, 0, &build_token_limit(&mint, 500_000)); ++ let mut session_data = build_session_data(&actions); ++ let snapshots = vec![TokenSnapshot { mint, amount: 500_000 }]; ++ ++ // token_spent = exactly remaining → OK ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 0, 0, &snapshots, 100, ++ ); ++ assert!(result.is_ok()); ++ ++ let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; ++ assert_eq!(read_u64(&session_data[abs_offset..], 32), 0); ++ } ++ ++ #[test] ++ fn test_token_limit_depletes_across_txs() { ++ let mint = [0xDD; 32]; ++ let actions = build_action(4, 0, &build_token_limit(&mint, 1_000_000)); ++ let mut session_data = build_session_data(&actions); ++ ++ // Tx 1: drain 600k ++ let s1 = vec![TokenSnapshot { mint, amount: 600_000 }]; ++ eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s1, 100).unwrap(); ++ ++ // remaining = 400k ++ let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; ++ assert_eq!(read_u64(&session_data[abs_offset..], 32), 400_000); ++ ++ // Tx 2: drain 400k → exact ++ let s2 = vec![TokenSnapshot { mint, amount: 400_000 }]; ++ eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s2, 101).unwrap(); ++ assert_eq!(read_u64(&session_data[abs_offset..], 32), 0); ++ ++ // Tx 3: drain 1 → fail ++ let s3 = vec![TokenSnapshot { mint, amount: 1 }]; ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s3, 102); ++ assert!(result.is_err()); ++ } ++ ++ // ── TokenMaxPerTx ─────────────────────────────────────────────── ++ ++ #[test] ++ fn test_token_max_per_tx_within_limit() { ++ let mint = [0xEE; 32]; ++ let actions = build_action(6, 0, &build_token_max_per_tx(&mint, 500_000)); ++ let mut session_data = build_session_data(&actions); ++ let snapshots = vec![TokenSnapshot { mint, amount: 300_000 }]; ++ ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); ++ assert!(result.is_ok()); ++ } ++ ++ #[test] ++ fn test_token_max_per_tx_exceeds() { ++ let mint = [0xFF; 32]; ++ let actions = build_action(6, 0, &build_token_max_per_tx(&mint, 500_000)); ++ let mut session_data = build_session_data(&actions); ++ let snapshots = vec![TokenSnapshot { mint, amount: 600_000 }]; ++ ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_token_max_per_tx_repeatable() { ++ // MaxPerTx has no cumulative state — repeated spends within limit all pass ++ let mint = [0x11; 32]; ++ let actions = build_action(6, 0, &build_token_max_per_tx(&mint, 500_000)); ++ let mut session_data = build_session_data(&actions); ++ ++ for slot in 100..105 { ++ let snapshots = vec![TokenSnapshot { mint, amount: 500_000 }]; ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, slot); ++ assert!(result.is_ok()); ++ } ++ } ++ ++ // ── TokenRecurringLimit ───────────────────────────────────────── ++ ++ #[test] ++ fn test_token_recurring_basic() { ++ let mint = [0x22; 32]; ++ let actions = build_action(5, 0, &build_token_recurring(&mint, 1_000_000, 0, 100, 0)); ++ let mut session_data = build_session_data(&actions); ++ ++ // Spend 600k at slot 50 — OK ++ let s1 = vec![TokenSnapshot { mint, amount: 600_000 }]; ++ eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s1, 50).unwrap(); ++ ++ // Spend 500k more at slot 60 → total 1.1M > 1M limit → fail ++ let s2 = vec![TokenSnapshot { mint, amount: 500_000 }]; ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s2, 60); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_token_recurring_window_reset() { ++ let mint = [0x33; 32]; ++ let actions = build_action(5, 0, &build_token_recurring(&mint, 1_000_000, 0, 100, 0)); ++ let mut session_data = build_session_data(&actions); ++ ++ // Spend 900k at slot 50 ++ let s1 = vec![TokenSnapshot { mint, amount: 900_000 }]; ++ eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s1, 50).unwrap(); ++ ++ // At slot 150 (after window), spending resets → 500k OK ++ let s2 = vec![TokenSnapshot { mint, amount: 500_000 }]; ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s2, 150); ++ assert!(result.is_ok()); ++ } ++ ++ // ── Expired token limits ───────────────────────────────────────── ++ ++ #[test] ++ fn test_expired_token_limit_blocks_spending() { ++ let mint = [0x44; 32]; ++ let actions = build_action(4, 50, &build_token_limit(&mint, 1_000_000)); // expires at slot 50 ++ let mut session_data = build_session_data(&actions); ++ let snapshots = vec![TokenSnapshot { mint, amount: 100 }]; ++ ++ // At slot 100 (expired), any token spend → fail ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_expired_token_max_per_tx_blocks_spending() { ++ let mint = [0x55; 32]; ++ let actions = build_action(6, 50, &build_token_max_per_tx(&mint, 1_000_000)); // expires at 50 ++ let mut session_data = build_session_data(&actions); ++ let snapshots = vec![TokenSnapshot { mint, amount: 1 }]; ++ ++ // Expired → even 1 token blocked ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_expired_token_recurring_blocks_spending() { ++ let mint = [0x66; 32]; ++ let actions = build_action(5, 50, &build_token_recurring(&mint, 1_000_000, 0, 100, 0)); ++ let mut session_data = build_session_data(&actions); ++ let snapshots = vec![TokenSnapshot { mint, amount: 1 }]; ++ ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); ++ assert!(result.is_err()); ++ } ++ ++ // ── Multiple mint limits ──────────────────────────────────────── ++ ++ #[test] ++ fn test_multiple_mints_independent() { ++ let mint_a = [0xAA; 32]; ++ let mint_b = [0xBB; 32]; ++ let mut actions_buf = Vec::new(); ++ actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_a, 100_000))); ++ actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_b, 500_000))); ++ let mut session_data = build_session_data(&actions_buf); ++ ++ // Drain mint_a within its limit, drain mint_b within its limit ++ let snapshots = vec![ ++ TokenSnapshot { mint: mint_a, amount: 50_000 }, ++ TokenSnapshot { mint: mint_b, amount: 400_000 }, ++ ]; ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); ++ assert!(result.is_ok()); ++ } ++ ++ #[test] ++ fn test_multiple_mints_one_exceeds() { ++ let mint_a = [0xAA; 32]; ++ let mint_b = [0xBB; 32]; ++ let mut actions_buf = Vec::new(); ++ actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_a, 100_000))); ++ actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_b, 500_000))); ++ let mut session_data = build_session_data(&actions_buf); ++ ++ // mint_a: 50k OK, mint_b: 600k > 500k → fail ++ let snapshots = vec![ ++ TokenSnapshot { mint: mint_a, amount: 50_000 }, ++ TokenSnapshot { mint: mint_b, amount: 600_000 }, ++ ]; ++ let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); ++ assert!(result.is_err()); ++ } ++ ++ // ── Combined SOL + Token limits ───────────────────────────────── ++ ++ #[test] ++ fn test_combined_sol_and_token_limits() { ++ let mint = [0xCC; 32]; ++ let mut actions_buf = Vec::new(); ++ actions_buf.extend_from_slice(&build_action(1, 0, &1_000_000u64.to_le_bytes())); // SolLimit: 1M ++ actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint, 500_000))); // TokenLimit: 500k ++ let mut session_data = build_session_data(&actions_buf); ++ ++ let snapshots = vec![TokenSnapshot { mint, amount: 300_000 }]; ++ ++ // SOL: 200k spent (under 1M), Token: 300k spent (under 500k) → OK ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 800_000, &snapshots, 100, ++ ); ++ assert!(result.is_ok()); ++ } ++ ++ #[test] ++ fn test_combined_sol_ok_token_exceeds() { ++ let mint = [0xDD; 32]; ++ let mut actions_buf = Vec::new(); ++ actions_buf.extend_from_slice(&build_action(1, 0, &10_000_000u64.to_le_bytes())); // SolLimit: 10M ++ actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint, 100_000))); // TokenLimit: 100k ++ let mut session_data = build_session_data(&actions_buf); ++ ++ let snapshots = vec![TokenSnapshot { mint, amount: 200_000 }]; ++ ++ // SOL: 500k spent (under 10M), Token: 200k > 100k → fail ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 5_000_000, 4_500_000, &snapshots, 100, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ // ══════════════════════════════════════════════════════════════════ ++ // Gross outflow tests (SolMaxPerTx uses gross, not net) ++ // ══════════════════════════════════════════════════════════════════ ++ ++ #[test] ++ fn test_sol_max_per_tx_gross_vs_net() { ++ // SolMaxPerTx = 1 SOL. A DeFi swap sends 10 SOL out and receives 9.5 back. ++ // Net = 0.5 SOL (would pass if using net), Gross = 10 SOL (must fail). ++ let actions = build_action(3, 0, &1_000_000_000u64.to_le_bytes()); // 1 SOL max ++ let mut session_data = build_session_data(&actions); ++ ++ // before=20 SOL, after=19.5 SOL → net = 0.5 SOL ++ // But gross = 10 SOL (passed explicitly) ++ let result = evaluate_post_actions( ++ &mut session_data, &[], &Pubkey::default(), ++ 20_000_000_000, 19_500_000_000, ++ 10_000_000_000, // gross = 10 SOL ++ &[], 100, ++ ); ++ assert!(result.is_err()); // 10 SOL gross > 1 SOL max → fail ++ } ++ ++ #[test] ++ fn test_sol_max_per_tx_gross_within_limit() { ++ let actions = build_action(3, 0, &5_000_000_000u64.to_le_bytes()); // 5 SOL max ++ let mut session_data = build_session_data(&actions); ++ ++ // Gross = 3 SOL, net = 1 SOL ++ let result = evaluate_post_actions( ++ &mut session_data, &[], &Pubkey::default(), ++ 20_000_000_000, 19_000_000_000, ++ 3_000_000_000, // gross = 3 SOL ++ &[], 100, ++ ); ++ assert!(result.is_ok()); // 3 SOL gross < 5 SOL max → OK ++ } ++ ++ #[test] ++ fn test_sol_limit_uses_net_not_gross() { ++ // SolLimit (cumulative) should use net, not gross. ++ // A round-trip that returns most lamports shouldn't deplete the budget. ++ let actions = build_action(1, 0, &2_000_000_000u64.to_le_bytes()); // 2 SOL limit ++ let mut session_data = build_session_data(&actions); ++ ++ // net = 0.5 SOL, gross = 10 SOL ++ let result = evaluate_post_actions( ++ &mut session_data, &[], &Pubkey::default(), ++ 20_000_000_000, 19_500_000_000, ++ 10_000_000_000, ++ &[], 100, ++ ); ++ assert!(result.is_ok()); // SolLimit uses net: 0.5 SOL < 2 SOL → OK ++ ++ let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; ++ let remaining = read_u64(&session_data[abs_offset..], 0); ++ assert_eq!(remaining, 1_500_000_000); // 2 SOL - 0.5 SOL net ++ } ++ ++ // ══════════════════════════════════════════════════════════════════ ++ // Attacker pattern tests ++ // ══════════════════════════════════════════════════════════════════ ++ ++ #[test] ++ fn test_attacker_all_limits_expired_session_locked() { ++ // Attacker scenario: create a session with a short-lived SolLimit. ++ // After expiry, the session should be locked — not unrestricted. ++ let mut actions_buf = Vec::new(); ++ actions_buf.extend_from_slice(&build_action(1, 50, &1_000_000u64.to_le_bytes())); // SolLimit, expires at 50 ++ actions_buf.extend_from_slice(&build_action(3, 50, &500_000u64.to_le_bytes())); // SolMaxPerTx, expires at 50 ++ let mut session_data = build_session_data(&actions_buf); ++ ++ // At slot 100 (both expired), even 1 lamport spend is blocked ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 999_999, &[], 100, ++ ); ++ assert!(result.is_err()); ++ ++ // Zero spend still OK ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 1_000_000, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ } ++ ++ #[test] ++ fn test_attacker_expired_token_and_sol_limits() { ++ // All limits expired — both SOL and token spending blocked ++ let mint = [0xFF; 32]; ++ let mut actions_buf = Vec::new(); ++ actions_buf.extend_from_slice(&build_action(1, 50, &1_000_000u64.to_le_bytes())); ++ actions_buf.extend_from_slice(&build_action(4, 50, &build_token_limit(&mint, 500_000))); ++ let mut session_data = build_session_data(&actions_buf); ++ ++ // SOL spend → blocked ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 999_000, &[], 100, ++ ); ++ assert!(result.is_err()); ++ ++ // Token spend → blocked ++ let snapshots = vec![TokenSnapshot { mint, amount: 100 }]; ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 1_000_000, 1_000_000, // no SOL change ++ &snapshots, 100, ++ ); ++ assert!(result.is_err()); ++ } ++ ++ #[test] ++ fn test_attacker_u64_max_overflow() { ++ // Attacker tries u64::MAX as remaining — should not cause overflow ++ let actions = build_action(1, 0, &u64::MAX.to_le_bytes()); ++ let mut session_data = build_session_data(&actions); ++ ++ // Spend u64::MAX → should succeed (exact match) ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ u64::MAX, 0, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ ++ let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; ++ assert_eq!(read_u64(&session_data[abs_offset..], 0), 0); ++ } ++ ++ #[test] ++ fn test_no_token_snapshot_means_no_change() { ++ // If a token mint has a limit but no before-snapshot, token_spent = 0 ++ let mint = [0xAA; 32]; ++ let actions = build_action(4, 0, &build_token_limit(&mint, 1_000_000)); ++ let mut session_data = build_session_data(&actions); ++ ++ // No snapshots → before=0, after=0 → spent=0 → OK ++ let result = eval_post( ++ &mut session_data, &[], &Pubkey::default(), ++ 0, 0, &[], 100, ++ ); ++ assert!(result.is_ok()); ++ } ++ ++ // ── evaluate_pre_actions tests ─────────────────────────────────── ++ // These test whitelist/blacklist logic directly. ++ // We create minimal CompactInstructions that reference account indexes. ++ ++ #[test] ++ fn test_pre_actions_no_actions_passthrough() { ++ let mut session_data = vec![0u8; SESSION_HEADER_SIZE]; ++ session_data[0] = 3; ++ ++ let result = evaluate_pre_actions(&session_data, &[], &[], 100); ++ assert!(result.is_ok()); ++ } ++ ++ // ── Session creation: actions_len cap ──────────────────────────── ++ // (tested in session/create.rs but we verify the constant here) ++ ++ #[test] ++ fn test_max_actions_constant_is_16() { ++ assert_eq!(crate::state::action::MAX_ACTIONS, 16); ++ } ++} +diff --git a/program/src/processor/execute_deferred.rs b/program/src/processor/execute_deferred.rs +index cc24c27..fafe0b4 100644 +--- a/program/src/processor/execute_deferred.rs ++++ b/program/src/processor/execute_deferred.rs +@@ -1,5 +1,5 @@ + use crate::{ +- compact::parse_compact_instructions, ++ compact::{parse_compact_instructions_ref_with_len, CompactInstructionRef}, + error::AuthError, + state::{deferred::DeferredExecAccount, AccountDiscriminator}, + }; +@@ -99,14 +99,14 @@ pub fn process( + return Err(AuthError::DeferredAuthorizationExpired.into()); + } + +- // Parse compact instructions +- let compact_instructions = parse_compact_instructions(instruction_data)?; ++ // Parse compact instructions and track consumed length. We hash the ++ // raw instruction_data[..consumed] directly — the parse/encode format ++ // is byte-identical, so there's no need to re-serialize. ++ let (compact_instructions, compact_len) = ++ parse_compact_instructions_ref_with_len(instruction_data)?; + +- // Serialize compact instructions to compute hash +- let compact_bytes = crate::compact::serialize_compact_instructions(&compact_instructions); +- +- // Verify instructions hash +- let instructions_hash = compute_sha256(&compact_bytes); ++ // Verify instructions hash against the exact bytes we parsed from ++ let instructions_hash = compute_sha256(&instruction_data[..compact_len]); + if instructions_hash != deferred.instructions_hash { + return Err(AuthError::DeferredHashMismatch.into()); + } +@@ -140,46 +140,47 @@ pub fn process( + let close_data = unsafe { deferred_pda.borrow_mut_data_unchecked() }; + close_data.fill(0); + ++ // Reuse Vecs across inner CPI iterations — allocated once, cleared + ++ // repushed each iteration. Same optimisation as execute::immediate. ++ const MAX_INNER_ACCOUNTS: usize = 32; ++ let mut account_metas: Vec = Vec::with_capacity(MAX_INNER_ACCOUNTS); ++ let mut cpi_accounts: Vec = Vec::with_capacity(MAX_INNER_ACCOUNTS); ++ ++ let vault_bump_arr = [vault_bump]; ++ let seeds = [ ++ Seed::from(b"vault"), ++ Seed::from(wallet_pda.key().as_ref()), ++ Seed::from(&vault_bump_arr), ++ ]; ++ + // Execute each compact instruction via CPI with vault PDA signing + for compact_ix in &compact_instructions { + let decompressed = compact_ix.decompress(accounts)?; + +- // Build AccountMeta array +- let account_metas: Vec = decompressed +- .accounts +- .iter() +- .map(|acc| AccountMeta { +- pubkey: acc.key(), +- is_signer: acc.is_signer() || acc.key() == vault_pda.key(), +- is_writable: acc.is_writable(), +- }) +- .collect(); +- + // Prevent self-reentrancy + if decompressed.program_id.as_ref() == program_id.as_ref() { + return Err(AuthError::SelfReentrancyNotAllowed.into()); + } + ++ account_metas.clear(); ++ cpi_accounts.clear(); ++ for &acc in &decompressed.accounts { ++ account_metas.push(AccountMeta { ++ pubkey: acc.key(), ++ is_signer: acc.is_signer() || acc.key() == vault_pda.key(), ++ is_writable: acc.is_writable(), ++ }); ++ cpi_accounts.push(Account::from(acc)); ++ } ++ + let ix = Instruction { + program_id: decompressed.program_id, + accounts: &account_metas, +- data: &decompressed.data, ++ data: decompressed.data, + }; + +- let vault_bump_arr = [vault_bump]; +- let seeds = [ +- Seed::from(b"vault"), +- Seed::from(wallet_pda.key().as_ref()), +- Seed::from(&vault_bump_arr), +- ]; + let signer: Signer = (&seeds).into(); + +- let cpi_accounts: Vec = decompressed +- .accounts +- .iter() +- .map(|acc| Account::from(*acc)) +- .collect(); +- + unsafe { + invoke_signed_unchecked(&ix, &cpi_accounts, &[signer]); + } +@@ -209,26 +210,26 @@ fn compute_sha256(data: &[u8]) -> [u8; 32] { + } + + /// Compute SHA256 hash of all account pubkeys referenced by compact instructions. +-/// Same logic as execute.rs::compute_accounts_hash. ++/// Matches execute::immediate::compute_accounts_hash. + fn compute_accounts_hash( + accounts: &[AccountInfo], +- compact_instructions: &[crate::compact::CompactInstruction], ++ compact_instructions: &[CompactInstructionRef<'_>], + ) -> Result<[u8; 32], ProgramError> { +- let mut pubkeys_data = Vec::new(); ++ let mut refs: Vec<&[u8]> = Vec::with_capacity(compact_instructions.len() * 4); + + for ix in compact_instructions { + let program_idx = ix.program_id_index as usize; + if program_idx >= accounts.len() { + return Err(ProgramError::InvalidInstructionData); + } +- pubkeys_data.extend_from_slice(accounts[program_idx].key().as_ref()); ++ refs.push(accounts[program_idx].key().as_ref()); + +- for &acc_idx in &ix.accounts { ++ for &acc_idx in ix.accounts { + let idx = acc_idx as usize; + if idx >= accounts.len() { + return Err(ProgramError::InvalidInstructionData); + } +- pubkeys_data.extend_from_slice(accounts[idx].key().as_ref()); ++ refs.push(accounts[idx].key().as_ref()); + } + } + +@@ -237,15 +238,15 @@ fn compute_accounts_hash( + #[cfg(target_os = "solana")] + unsafe { + pinocchio::syscalls::sol_sha256( +- [pubkeys_data.as_slice()].as_ptr() as *const u8, +- 1, ++ refs.as_ptr() as *const u8, ++ refs.len() as u64, + hash.as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { + hash = [0xAA; 32]; +- let _ = pubkeys_data; ++ let _ = refs; + } + + Ok(hash) +diff --git a/program/src/processor/manage_authority.rs b/program/src/processor/manage_authority.rs +index 34d6073..1e165b7 100644 +--- a/program/src/processor/manage_authority.rs ++++ b/program/src/processor/manage_authority.rs +@@ -91,6 +91,9 @@ pub fn process_add_authority( + } + let (credential_id_hash, rest_after_cred) = rest.split_at(32); + let rp_id_len = rest_after_cred[33] as usize; ++ if rp_id_len == 0 || rp_id_len > 253 { ++ return Err(ProgramError::InvalidInstructionData); ++ } + let total_auth_data = 32 + 33 + 1 + rp_id_len; + if rest.len() < total_auth_data { + return Err(ProgramError::InvalidInstructionData); +@@ -198,6 +201,12 @@ pub fn process_add_authority( + } + + // Authorization ++ // Validate new_role is a known value (0=Owner, 1=Admin, 2=Spender). ++ // Without this check an Owner could create a role-255 authority that can ++ // execute but cannot be revoked by any Admin. ++ if args.new_role > 2 { ++ return Err(AuthError::PermissionDenied.into()); ++ } + if admin_header.role != 0 && (admin_header.role != 1 || args.new_role != 2) { + return Err(AuthError::PermissionDenied.into()); + } +@@ -212,9 +221,13 @@ pub fn process_add_authority( + } + check_zero_data(new_auth_pda, ProgramError::AccountAlreadyInitialized)?; + ++ // Fixed sizes per auth type (see wallet/create.rs for layout). + let header_size = std::mem::size_of::(); +- let variable_size = full_auth_data.len(); +- let space = header_size + variable_size; ++ let space = match args.authority_type { ++ 0 => header_size + 32, // Ed25519: pubkey ++ 1 => header_size + 32 + 33 + 32, // Secp256r1: cred ∥ pubkey ∥ rpIdHash ++ _ => return Err(AuthError::InvalidAuthenticationKind.into()), ++ }; + let rent_lamports = rent.minimum_balance(space); + + // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) +@@ -252,8 +265,35 @@ pub fn process_add_authority( + *(data.as_mut_ptr() as *mut AuthorityAccountHeader) = header; + } + +- let variable_target = &mut data[header_size..]; +- variable_target.copy_from_slice(full_auth_data); ++ // Write variable data. For Secp256r1 hash rpId once here so every Execute ++ // saves a sol_sha256 syscall. ++ match args.authority_type { ++ 0 => { ++ data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); ++ } ++ 1 => { ++ data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); ++ data[header_size + 32..header_size + 32 + 33] ++ .copy_from_slice(&full_auth_data[32..32 + 33]); ++ let rp_id_len = full_auth_data[32 + 33] as usize; ++ let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; ++ let rp_id_hash_offset = header_size + 32 + 33; ++ #[cfg(target_os = "solana")] ++ unsafe { ++ let _ = pinocchio::syscalls::sol_sha256( ++ [rp_id].as_ptr() as *const u8, ++ 1, ++ data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), ++ ); ++ } ++ #[cfg(not(target_os = "solana"))] ++ { ++ let _ = rp_id; ++ data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); ++ } ++ } ++ _ => unreachable!(), ++ } + + Ok(()) + } +@@ -398,6 +438,12 @@ pub fn process_remove_authority( + } + } + ++ // Guard: if target == refund_dest the double-write would burn lamports and ++ // trigger a Solana lamport conservation error, aborting after doing work. ++ if target_auth_pda.key() == refund_dest.key() { ++ return Err(ProgramError::InvalidAccountData); ++ } ++ + let target_lamports = unsafe { *target_auth_pda.borrow_mut_lamports_unchecked() }; + let refund_lamports = unsafe { *refund_dest.borrow_mut_lamports_unchecked() }; + unsafe { +diff --git a/program/src/processor/mod.rs b/program/src/processor/mod.rs +index a777116..2d2086a 100644 +--- a/program/src/processor/mod.rs ++++ b/program/src/processor/mod.rs +@@ -6,6 +6,7 @@ pub mod authorize; + pub mod create_session; + pub mod create_wallet; + pub mod execute; ++pub mod execute_actions; + pub mod execute_deferred; + pub mod manage_authority; + pub mod reclaim_deferred; +diff --git a/program/src/processor/revoke_session.rs b/program/src/processor/revoke_session.rs +index dae1b39..aeada67 100644 +--- a/program/src/processor/revoke_session.rs ++++ b/program/src/processor/revoke_session.rs +@@ -136,6 +136,11 @@ pub fn process( + return Err(ProgramError::InvalidAccountData); + } + ++ // Guard: session_pda == refund_dest would burn lamports. ++ if session_pda.key() == refund_dest.key() { ++ return Err(ProgramError::InvalidAccountData); ++ } ++ + // Close the session account — zero data and drain lamports + session_data.fill(0); + +diff --git a/program/src/processor/transfer_ownership.rs b/program/src/processor/transfer_ownership.rs +index 51dc8ad..0800f84 100644 +--- a/program/src/processor/transfer_ownership.rs ++++ b/program/src/processor/transfer_ownership.rs +@@ -27,14 +27,16 @@ use crate::{ + /// 2. **Authorization**: strictly enforced to only work if `current_owner` has `Role::Owner` (0). + /// 3. **Atomic Swap**: + /// - Creates the `new_owner` account. +-/// - Closes the `current_owner` account and refunds rent to payer. ++/// - Closes the `current_owner` account and refunds rent to `refund_dest`. + /// + /// # Accounts: + /// 1. `[signer, writable]` Payer. + /// 2. `[]` Wallet PDA. + /// 3. `[signer, writable]` Current Owner Authority. + /// 4. `[writable]` New Owner Authority. +-/// 5. `[]` System Program. ++/// 5. `[writable]` Refund Destination (receives closed current_owner rent). ++/// 6. `[]` System Program. ++/// 7. `[]` Rent Sysvar. + /// + /// Arguments for the `TransferOwnership` instruction. + /// +@@ -80,6 +82,9 @@ pub fn process( + } + let (hash, rest_after_hash) = rest.split_at(32); + let rp_id_len = rest_after_hash[33] as usize; ++ if rp_id_len == 0 || rp_id_len > 253 { ++ return Err(ProgramError::InvalidInstructionData); ++ } + let total_auth_data = 32 + 33 + 1 + rp_id_len; + if rest.len() < total_auth_data { + return Err(ProgramError::InvalidInstructionData); +@@ -115,6 +120,9 @@ pub fn process( + let new_owner = account_info_iter + .next() + .ok_or(ProgramError::NotEnoughAccountKeys)?; ++ let refund_dest = account_info_iter ++ .next() ++ .ok_or(ProgramError::NotEnoughAccountKeys)?; + let system_program = account_info_iter + .next() + .ok_or(ProgramError::NotEnoughAccountKeys)?; +@@ -126,6 +134,11 @@ pub fn process( + if wallet_pda.owner() != program_id || current_owner.owner() != program_id { + return Err(ProgramError::IllegalOwner); + } ++ ++ // Guard: closing current_owner to itself would burn lamports. ++ if current_owner.key() == refund_dest.key() { ++ return Err(ProgramError::InvalidAccountData); ++ } + // Validate Wallet Discriminator (Issue #7) + let wallet_data = unsafe { wallet_pda.borrow_data_unchecked() }; + if wallet_data.is_empty() || wallet_data[0] != AccountDiscriminator::Wallet as u8 { +@@ -155,15 +168,16 @@ pub fn process( + return Err(AuthError::PermissionDenied.into()); + } + +- // Authenticate Current Owner +- // Issue: Include payer + new_owner to prevent rent theft via payer swap +- let mut ed25519_payload = Vec::with_capacity(64); ++ // Authenticate Current Owner. ++ // Sign over payer + new_owner + refund_dest to prevent substitution attacks. ++ let mut ed25519_payload = Vec::with_capacity(96); + ed25519_payload.extend_from_slice(payer.key().as_ref()); + ed25519_payload.extend_from_slice(new_owner.key().as_ref()); ++ ed25519_payload.extend_from_slice(refund_dest.key().as_ref()); + + match auth.authority_type { + 0 => { +- // Ed25519: Include payer + new_owner in signed payload ++ // Ed25519: sign over payer + new_owner + refund_dest + Ed25519Authenticator.authenticate(accounts, data, &[], &ed25519_payload, &[3], program_id)?; + }, + 1 => { +@@ -171,10 +185,11 @@ pub fn process( + if !current_owner.is_writable() { + return Err(ProgramError::InvalidAccountData); + } +- // Secp256r1: Include payer in signed payload to prevent rent theft +- let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 32); ++ // Sign over data_payload + payer + refund_dest ++ let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 64); + extended_data_payload.extend_from_slice(data_payload); + extended_data_payload.extend_from_slice(payer.key().as_ref()); ++ extended_data_payload.extend_from_slice(refund_dest.key().as_ref()); + + Secp256r1Authenticator.authenticate( + accounts, +@@ -198,9 +213,13 @@ pub fn process( + } + check_zero_data(new_owner, ProgramError::AccountAlreadyInitialized)?; + ++ // Fixed sizes per auth type (see wallet/create.rs for layout). + let header_size = std::mem::size_of::(); +- let variable_size = full_auth_data.len(); +- let space = header_size + variable_size; ++ let space = match args.auth_type { ++ 0 => header_size + 32, // Ed25519: pubkey ++ 1 => header_size + 32 + 33 + 32, // Secp256r1: cred ∥ pubkey ∥ rpIdHash ++ _ => return Err(AuthError::InvalidAuthenticationKind.into()), ++ }; + let rent = rent_obj.minimum_balance(space); + + // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) +@@ -241,13 +260,40 @@ pub fn process( + std::ptr::write_unaligned(data.as_mut_ptr() as *mut AuthorityAccountHeader, header); + } + +- let variable_target = &mut data[header_size..]; +- variable_target.copy_from_slice(full_auth_data); ++ // Write variable data. For Secp256r1 hash rpId once here so every Execute ++ // saves a sol_sha256 syscall. ++ match args.auth_type { ++ 0 => { ++ data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); ++ } ++ 1 => { ++ data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); ++ data[header_size + 32..header_size + 32 + 33] ++ .copy_from_slice(&full_auth_data[32..32 + 33]); ++ let rp_id_len = full_auth_data[32 + 33] as usize; ++ let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; ++ let rp_id_hash_offset = header_size + 32 + 33; ++ #[cfg(target_os = "solana")] ++ unsafe { ++ let _ = pinocchio::syscalls::sol_sha256( ++ [rp_id].as_ptr() as *const u8, ++ 1, ++ data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), ++ ); ++ } ++ #[cfg(not(target_os = "solana"))] ++ { ++ let _ = rp_id; ++ data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); ++ } ++ } ++ _ => unreachable!(), ++ } + + let current_lamports = unsafe { *current_owner.borrow_mut_lamports_unchecked() }; +- let payer_lamports = unsafe { *payer.borrow_mut_lamports_unchecked() }; ++ let refund_lamports = unsafe { *refund_dest.borrow_mut_lamports_unchecked() }; + unsafe { +- *payer.borrow_mut_lamports_unchecked() = payer_lamports ++ *refund_dest.borrow_mut_lamports_unchecked() = refund_lamports + .checked_add(current_lamports) + .ok_or(ProgramError::ArithmeticOverflow)?; + *current_owner.borrow_mut_lamports_unchecked() = 0; +diff --git a/program/src/state/action.rs b/program/src/state/action.rs +new file mode 100644 +index 0000000..7195fa4 +--- /dev/null ++++ b/program/src/state/action.rs +@@ -0,0 +1,697 @@ ++//! Session action types for permission enforcement. ++//! ++//! Actions are optional, immutable permission rules attached to sessions at creation time. ++//! They are stored as a flat byte buffer appended after the 80-byte SessionAccount header. ++//! ++//! Each action has an 11-byte header: [type: u8][data_len: u16 LE][expires_at: u64 LE] ++//! followed by type-specific data bytes. ++ ++use pinocchio::program_error::ProgramError; ++ ++use crate::error::AuthError; ++ ++// ─── Action Header ──────────────────────────────────────────────────── ++ ++/// Size of each action header in bytes. ++pub const ACTION_HEADER_SIZE: usize = 11; ++ ++/// Maximum number of actions per session. ++pub const MAX_ACTIONS: usize = 16; ++ ++// ─── Action Types ───────────────────────────────────────────────────── ++ ++#[derive(Debug, Clone, Copy, PartialEq, Eq)] ++#[repr(u8)] ++pub enum ActionType { ++ /// Lifetime SOL spending cap. Data: {remaining: u64} ++ SolLimit = 1, ++ /// Per-window SOL spending cap. Data: {limit, spent, window, last_reset} ++ SolRecurringLimit = 2, ++ /// Maximum SOL per single execute. Data: {max: u64} ++ SolMaxPerTx = 3, ++ /// Lifetime token spending cap per mint. Data: {mint: [u8;32], remaining: u64} ++ TokenLimit = 4, ++ /// Per-window token spending cap per mint. Data: {mint, limit, spent, window, last_reset} ++ TokenRecurringLimit = 5, ++ /// Maximum tokens per single execute per mint. Data: {mint: [u8;32], max: u64} ++ TokenMaxPerTx = 6, ++ /// Allow CPI only to this program. Repeatable. Data: {program_id: [u8;32]} ++ ProgramWhitelist = 10, ++ /// Block CPI to this program. Repeatable. Data: {program_id: [u8;32]} ++ ProgramBlacklist = 11, ++} ++ ++impl ActionType { ++ pub fn from_u8(v: u8) -> Result { ++ match v { ++ 1 => Ok(Self::SolLimit), ++ 2 => Ok(Self::SolRecurringLimit), ++ 3 => Ok(Self::SolMaxPerTx), ++ 4 => Ok(Self::TokenLimit), ++ 5 => Ok(Self::TokenRecurringLimit), ++ 6 => Ok(Self::TokenMaxPerTx), ++ 10 => Ok(Self::ProgramWhitelist), ++ 11 => Ok(Self::ProgramBlacklist), ++ _ => Err(AuthError::ActionBufferInvalid.into()), ++ } ++ } ++ ++ /// Expected data size for this action type (excluding header). ++ pub fn expected_data_size(&self) -> usize { ++ match self { ++ Self::SolLimit => SOL_LIMIT_SIZE, ++ Self::SolRecurringLimit => SOL_RECURRING_LIMIT_SIZE, ++ Self::SolMaxPerTx => SOL_MAX_PER_TX_SIZE, ++ Self::TokenLimit => TOKEN_LIMIT_SIZE, ++ Self::TokenRecurringLimit => TOKEN_RECURRING_LIMIT_SIZE, ++ Self::TokenMaxPerTx => TOKEN_MAX_PER_TX_SIZE, ++ Self::ProgramWhitelist => PROGRAM_WHITELIST_SIZE, ++ Self::ProgramBlacklist => PROGRAM_BLACKLIST_SIZE, ++ } ++ } ++} ++ ++// ─── Data Sizes ─────────────────────────────────────────────────────── ++ ++pub const SOL_LIMIT_SIZE: usize = 8; ++pub const SOL_RECURRING_LIMIT_SIZE: usize = 32; ++pub const SOL_MAX_PER_TX_SIZE: usize = 8; ++pub const TOKEN_LIMIT_SIZE: usize = 40; ++pub const TOKEN_RECURRING_LIMIT_SIZE: usize = 64; ++pub const TOKEN_MAX_PER_TX_SIZE: usize = 40; ++pub const PROGRAM_WHITELIST_SIZE: usize = 32; ++pub const PROGRAM_BLACKLIST_SIZE: usize = 32; ++ ++// ─── Action View (zero-copy index into buffer) ─────────────────────── ++ ++/// A parsed reference to an action within the session data buffer. ++/// Does not own data — just indexes into the buffer. ++#[derive(Debug, Clone)] ++pub struct ActionView { ++ pub action_type: ActionType, ++ pub expires_at: u64, ++ /// Byte offset of this action's data within the actions buffer ++ /// (relative to start of actions buffer, NOT session account start). ++ pub data_offset: usize, ++ pub data_len: usize, ++} ++ ++// ─── Buffer Parsing ─────────────────────────────────────────────────── ++ ++/// Parse all actions from a raw actions buffer. ++/// ++/// The buffer starts immediately after the 80-byte session header. ++/// Returns a Vec of ActionViews indexing into the buffer. ++pub fn parse_actions(buf: &[u8]) -> Result, ProgramError> { ++ let mut actions = Vec::new(); ++ let mut cursor = 0; ++ ++ while cursor < buf.len() { ++ if cursor + ACTION_HEADER_SIZE > buf.len() { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ ++ let action_type = ActionType::from_u8(buf[cursor])?; ++ let data_len = u16::from_le_bytes([buf[cursor + 1], buf[cursor + 2]]) as usize; ++ let expires_at = u64::from_le_bytes( ++ buf[cursor + 3..cursor + 11] ++ .try_into() ++ .map_err(|_| AuthError::ActionBufferInvalid)?, ++ ); ++ ++ let data_offset = cursor + ACTION_HEADER_SIZE; ++ if data_offset + data_len > buf.len() { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ ++ actions.push(ActionView { ++ action_type, ++ expires_at, ++ data_offset, ++ data_len, ++ }); ++ ++ cursor = data_offset + data_len; ++ ++ if actions.len() > MAX_ACTIONS { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ } ++ ++ Ok(actions) ++} ++ ++/// Validate an actions buffer at session creation time. ++/// ++/// Checks: ++/// - All action types are known ++/// - Data sizes match expected sizes per type ++/// - No simultaneous ProgramWhitelist + ProgramBlacklist ++/// - Buffer is fully consumed (no trailing bytes) ++/// - Not exceeding MAX_ACTIONS ++pub fn validate_actions_buffer(buf: &[u8]) -> Result<(), ProgramError> { ++ if buf.is_empty() { ++ return Ok(()); ++ } ++ ++ let actions = parse_actions(buf)?; ++ ++ // Verify data sizes match expected ++ for action in &actions { ++ if action.data_len != action.action_type.expected_data_size() { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ } ++ ++ // Check no whitelist + blacklist coexistence ++ let has_whitelist = actions ++ .iter() ++ .any(|a| a.action_type == ActionType::ProgramWhitelist); ++ let has_blacklist = actions ++ .iter() ++ .any(|a| a.action_type == ActionType::ProgramBlacklist); ++ if has_whitelist && has_blacklist { ++ return Err(AuthError::ActionWhitelistBlacklistConflict.into()); ++ } ++ ++ // Check no duplicate non-repeatable actions ++ let mut has_sol_limit = false; ++ let mut has_sol_recurring = false; ++ let mut has_sol_max_per_tx = false; ++ for action in &actions { ++ match action.action_type { ++ ActionType::SolLimit => { ++ if has_sol_limit { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ has_sol_limit = true; ++ } ++ ActionType::SolRecurringLimit => { ++ if has_sol_recurring { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ has_sol_recurring = true; ++ } ++ ActionType::SolMaxPerTx => { ++ if has_sol_max_per_tx { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ has_sol_max_per_tx = true; ++ } ++ _ => {} // Repeatable types are fine ++ } ++ } ++ ++ // Check no duplicate token actions for the same mint ++ // (Two TokenLimit for the same mint would create confusing deduction semantics) ++ { ++ let token_types = [ ++ ActionType::TokenLimit, ++ ActionType::TokenRecurringLimit, ++ ActionType::TokenMaxPerTx, ++ ]; ++ for token_type in &token_types { ++ let token_actions: Vec<&ActionView> = actions ++ .iter() ++ .filter(|a| &a.action_type == token_type) ++ .collect(); ++ for i in 0..token_actions.len() { ++ for j in (i + 1)..token_actions.len() { ++ let mint_a = &buf[token_actions[i].data_offset..token_actions[i].data_offset + 32]; ++ let mint_b = &buf[token_actions[j].data_offset..token_actions[j].data_offset + 32]; ++ if mint_a == mint_b { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ } ++ } ++ } ++ } ++ ++ // Validate recurring limit initial state ++ for action in &actions { ++ if action.action_type == ActionType::SolRecurringLimit { ++ let data = &buf[action.data_offset..action.data_offset + action.data_len]; ++ // spent must be 0 at creation ++ let spent = u64::from_le_bytes(data[8..16].try_into().unwrap()); ++ if spent != 0 { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ // window must be > 0 ++ let window = u64::from_le_bytes(data[16..24].try_into().unwrap()); ++ if window == 0 { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ // last_reset must be 0 ++ let last_reset = u64::from_le_bytes(data[24..32].try_into().unwrap()); ++ if last_reset != 0 { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ } ++ if action.action_type == ActionType::TokenRecurringLimit { ++ let data = &buf[action.data_offset..action.data_offset + action.data_len]; ++ // spent must be 0 (bytes 40..48) ++ let spent = u64::from_le_bytes(data[40..48].try_into().unwrap()); ++ if spent != 0 { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ // window must be > 0 (bytes 48..56) ++ let window = u64::from_le_bytes(data[48..56].try_into().unwrap()); ++ if window == 0 { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ // last_reset must be 0 (bytes 56..64) ++ let last_reset = u64::from_le_bytes(data[56..64].try_into().unwrap()); ++ if last_reset != 0 { ++ return Err(AuthError::ActionBufferInvalid.into()); ++ } ++ } ++ } ++ ++ Ok(()) ++} ++ ++// ─── Data Layout Helpers ────────────────────────────────────────────── ++ ++// SolLimit: [remaining: u64] = 8 bytes ++// Offsets within data: remaining = 0..8 ++ ++// SolRecurringLimit: [limit: u64][spent: u64][window: u64][last_reset: u64] = 32 bytes ++// Offsets: limit = 0..8, spent = 8..16, window = 16..24, last_reset = 24..32 ++ ++// SolMaxPerTx: [max: u64] = 8 bytes ++// Offsets: max = 0..8 ++ ++// TokenLimit: [mint: [u8;32]][remaining: u64] = 40 bytes ++// Offsets: mint = 0..32, remaining = 32..40 ++ ++// TokenRecurringLimit: [mint: [u8;32]][limit: u64][spent: u64][window: u64][last_reset: u64] = 64 bytes ++// Offsets: mint = 0..32, limit = 32..40, spent = 40..48, window = 48..56, last_reset = 56..64 ++ ++// TokenMaxPerTx: [mint: [u8;32]][max: u64] = 40 bytes ++// Offsets: mint = 0..32, max = 32..40 ++ ++// ProgramWhitelist: [program_id: [u8;32]] = 32 bytes ++// ProgramBlacklist: [program_id: [u8;32]] = 32 bytes ++ ++/// Read a u64 from a byte slice at the given offset (LE). ++#[inline(always)] ++pub fn read_u64(data: &[u8], offset: usize) -> u64 { ++ u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) ++} ++ ++/// Write a u64 to a byte slice at the given offset (LE). ++#[inline(always)] ++pub fn write_u64(data: &mut [u8], offset: usize, value: u64) { ++ data[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); ++} ++ ++// ─── Tests ──────────────────────────────────────────────────────────── ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ fn build_action(action_type: u8, expires_at: u64, data: &[u8]) -> Vec { ++ let mut buf = Vec::new(); ++ buf.push(action_type); ++ buf.extend_from_slice(&(data.len() as u16).to_le_bytes()); ++ buf.extend_from_slice(&expires_at.to_le_bytes()); ++ buf.extend_from_slice(data); ++ buf ++ } ++ ++ #[test] ++ fn test_parse_empty_buffer() { ++ let actions = parse_actions(&[]).unwrap(); ++ assert!(actions.is_empty()); ++ } ++ ++ #[test] ++ fn test_parse_sol_limit() { ++ let data = 1_000_000u64.to_le_bytes(); ++ let buf = build_action(1, 0, &data); ++ let actions = parse_actions(&buf).unwrap(); ++ assert_eq!(actions.len(), 1); ++ assert_eq!(actions[0].action_type, ActionType::SolLimit); ++ assert_eq!(actions[0].expires_at, 0); ++ assert_eq!(actions[0].data_len, 8); ++ } ++ ++ #[test] ++ fn test_parse_sol_recurring_limit() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit ++ data.extend_from_slice(&0u64.to_le_bytes()); // spent ++ data.extend_from_slice(&216_000u64.to_le_bytes()); // window (~1 day) ++ data.extend_from_slice(&0u64.to_le_bytes()); // last_reset ++ let buf = build_action(2, 0, &data); ++ let actions = parse_actions(&buf).unwrap(); ++ assert_eq!(actions.len(), 1); ++ assert_eq!(actions[0].action_type, ActionType::SolRecurringLimit); ++ assert_eq!(actions[0].data_len, 32); ++ } ++ ++ #[test] ++ fn test_parse_program_whitelist_multiple() { ++ let prog1 = [1u8; 32]; ++ let prog2 = [2u8; 32]; ++ let mut buf = build_action(10, 0, &prog1); ++ buf.extend_from_slice(&build_action(10, 0, &prog2)); ++ let actions = parse_actions(&buf).unwrap(); ++ assert_eq!(actions.len(), 2); ++ assert_eq!(actions[0].action_type, ActionType::ProgramWhitelist); ++ assert_eq!(actions[1].action_type, ActionType::ProgramWhitelist); ++ } ++ ++ #[test] ++ fn test_parse_multiple_action_types() { ++ let mut buf = Vec::new(); ++ // SolMaxPerTx ++ buf.extend_from_slice(&build_action(3, 0, &500_000u64.to_le_bytes())); ++ // ProgramWhitelist ++ buf.extend_from_slice(&build_action(10, 0, &[0xAA; 32])); ++ let actions = parse_actions(&buf).unwrap(); ++ assert_eq!(actions.len(), 2); ++ assert_eq!(actions[0].action_type, ActionType::SolMaxPerTx); ++ assert_eq!(actions[1].action_type, ActionType::ProgramWhitelist); ++ } ++ ++ #[test] ++ fn test_parse_with_expiry() { ++ let data = 1_000_000u64.to_le_bytes(); ++ let buf = build_action(1, 5000, &data); ++ let actions = parse_actions(&buf).unwrap(); ++ assert_eq!(actions[0].expires_at, 5000); ++ } ++ ++ #[test] ++ fn test_validate_unknown_type() { ++ let buf = build_action(99, 0, &[0u8; 8]); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_wrong_data_size() { ++ // SolLimit expects 8 bytes, give it 16 ++ let buf = build_action(1, 0, &[0u8; 16]); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_whitelist_blacklist_conflict() { ++ let mut buf = build_action(10, 0, &[1u8; 32]); // whitelist ++ buf.extend_from_slice(&build_action(11, 0, &[2u8; 32])); // blacklist ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_duplicate_sol_limit() { ++ let mut buf = build_action(1, 0, &1_000_000u64.to_le_bytes()); ++ buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_recurring_nonzero_spent() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit ++ data.extend_from_slice(&100u64.to_le_bytes()); // spent (should be 0!) ++ data.extend_from_slice(&216_000u64.to_le_bytes()); // window ++ data.extend_from_slice(&0u64.to_le_bytes()); // last_reset ++ let buf = build_action(2, 0, &data); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_recurring_zero_window() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit ++ data.extend_from_slice(&0u64.to_le_bytes()); // spent ++ data.extend_from_slice(&0u64.to_le_bytes()); // window = 0 (invalid!) ++ data.extend_from_slice(&0u64.to_le_bytes()); // last_reset ++ let buf = build_action(2, 0, &data); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_empty_ok() { ++ assert!(validate_actions_buffer(&[]).is_ok()); ++ } ++ ++ #[test] ++ fn test_validate_valid_combined() { ++ let mut buf = Vec::new(); ++ // SolRecurringLimit ++ let mut sol_rec = Vec::new(); ++ sol_rec.extend_from_slice(&1_000_000u64.to_le_bytes()); ++ sol_rec.extend_from_slice(&0u64.to_le_bytes()); ++ sol_rec.extend_from_slice(&216_000u64.to_le_bytes()); ++ sol_rec.extend_from_slice(&0u64.to_le_bytes()); ++ buf.extend_from_slice(&build_action(2, 0, &sol_rec)); ++ // ProgramWhitelist ++ buf.extend_from_slice(&build_action(10, 0, &[0xBB; 32])); ++ // SolMaxPerTx ++ buf.extend_from_slice(&build_action(3, 0, &500_000u64.to_le_bytes())); ++ assert!(validate_actions_buffer(&buf).is_ok()); ++ } ++ ++ #[test] ++ fn test_truncated_header() { ++ let buf = vec![1u8, 0]; // Only 2 bytes, need 11 ++ assert!(parse_actions(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_truncated_data() { ++ let mut buf = Vec::new(); ++ buf.push(1); // type ++ buf.extend_from_slice(&8u16.to_le_bytes()); // data_len = 8 ++ buf.extend_from_slice(&0u64.to_le_bytes()); // expires_at ++ buf.extend_from_slice(&[0u8; 4]); // only 4 bytes of data (need 8) ++ assert!(parse_actions(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_token_max_per_tx() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&[0xCC; 32]); // mint ++ data.extend_from_slice(&1_000_000u64.to_le_bytes()); // max ++ let buf = build_action(6, 0, &data); ++ let actions = parse_actions(&buf).unwrap(); ++ assert_eq!(actions[0].action_type, ActionType::TokenMaxPerTx); ++ assert!(validate_actions_buffer(&buf).is_ok()); ++ } ++ ++ #[test] ++ fn test_read_write_u64() { ++ let mut data = [0u8; 16]; ++ write_u64(&mut data, 0, 12345); ++ write_u64(&mut data, 8, 67890); ++ assert_eq!(read_u64(&data, 0), 12345); ++ assert_eq!(read_u64(&data, 8), 67890); ++ } ++ ++ // ─── Security: Duplicate token mint validation (Audit Finding 6) ── ++ ++ #[test] ++ fn test_validate_duplicate_token_limit_same_mint() { ++ let mint = [0xAA; 32]; ++ let mut data1 = Vec::new(); ++ data1.extend_from_slice(&mint); ++ data1.extend_from_slice(&1_000_000u64.to_le_bytes()); ++ ++ let mut data2 = Vec::new(); ++ data2.extend_from_slice(&mint); // same mint! ++ data2.extend_from_slice(&2_000_000u64.to_le_bytes()); ++ ++ let mut buf = build_action(4, 0, &data1); // TokenLimit ++ buf.extend_from_slice(&build_action(4, 0, &data2)); // TokenLimit same mint ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_duplicate_token_recurring_same_mint() { ++ let mint = [0xBB; 32]; ++ let mut make_data = || { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&mint); ++ data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit ++ data.extend_from_slice(&0u64.to_le_bytes()); // spent ++ data.extend_from_slice(&100u64.to_le_bytes()); // window ++ data.extend_from_slice(&0u64.to_le_bytes()); // last_reset ++ data ++ }; ++ ++ let mut buf = build_action(5, 0, &make_data()); // TokenRecurringLimit ++ buf.extend_from_slice(&build_action(5, 0, &make_data())); // same mint ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_different_token_mints_ok() { ++ let mut data1 = Vec::new(); ++ data1.extend_from_slice(&[0xAA; 32]); // mint A ++ data1.extend_from_slice(&1_000_000u64.to_le_bytes()); ++ ++ let mut data2 = Vec::new(); ++ data2.extend_from_slice(&[0xBB; 32]); // mint B (different!) ++ data2.extend_from_slice(&2_000_000u64.to_le_bytes()); ++ ++ let mut buf = build_action(4, 0, &data1); ++ buf.extend_from_slice(&build_action(4, 0, &data2)); ++ assert!(validate_actions_buffer(&buf).is_ok()); ++ } ++ ++ #[test] ++ fn test_validate_same_mint_different_action_types_ok() { ++ // Same mint in TokenLimit and TokenMaxPerTx is OK (different action types) ++ let mint = [0xCC; 32]; ++ let mut data_limit = Vec::new(); ++ data_limit.extend_from_slice(&mint); ++ data_limit.extend_from_slice(&1_000_000u64.to_le_bytes()); ++ ++ let mut data_max = Vec::new(); ++ data_max.extend_from_slice(&mint); ++ data_max.extend_from_slice(&500_000u64.to_le_bytes()); ++ ++ let mut buf = build_action(4, 0, &data_limit); // TokenLimit ++ buf.extend_from_slice(&build_action(6, 0, &data_max)); // TokenMaxPerTx ++ assert!(validate_actions_buffer(&buf).is_ok()); ++ } ++ ++ // ─── Security: Duplicate SOL actions ────────────────────────────── ++ ++ #[test] ++ fn test_validate_duplicate_sol_recurring_limit() { ++ let make_data = || { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&1_000_000u64.to_le_bytes()); ++ data.extend_from_slice(&0u64.to_le_bytes()); ++ data.extend_from_slice(&100u64.to_le_bytes()); ++ data.extend_from_slice(&0u64.to_le_bytes()); ++ data ++ }; ++ let mut buf = build_action(2, 0, &make_data()); ++ buf.extend_from_slice(&build_action(2, 0, &make_data())); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_duplicate_sol_max_per_tx() { ++ let mut buf = build_action(3, 0, &500_000u64.to_le_bytes()); ++ buf.extend_from_slice(&build_action(3, 0, &300_000u64.to_le_bytes())); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ // ─── Security: Token recurring limit initial state ──────────────── ++ ++ #[test] ++ fn test_validate_token_recurring_nonzero_spent() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&[0xAA; 32]); // mint ++ data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit ++ data.extend_from_slice(&100u64.to_le_bytes()); // spent (should be 0!) ++ data.extend_from_slice(&100u64.to_le_bytes()); // window ++ data.extend_from_slice(&0u64.to_le_bytes()); // last_reset ++ let buf = build_action(5, 0, &data); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_token_recurring_zero_window() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&[0xAA; 32]); // mint ++ data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit ++ data.extend_from_slice(&0u64.to_le_bytes()); // spent ++ data.extend_from_slice(&0u64.to_le_bytes()); // window = 0! ++ data.extend_from_slice(&0u64.to_le_bytes()); // last_reset ++ let buf = build_action(5, 0, &data); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_token_recurring_nonzero_last_reset() { ++ let mut data = Vec::new(); ++ data.extend_from_slice(&[0xAA; 32]); // mint ++ data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit ++ data.extend_from_slice(&0u64.to_le_bytes()); // spent ++ data.extend_from_slice(&100u64.to_le_bytes()); // window ++ data.extend_from_slice(&50u64.to_le_bytes()); // last_reset != 0! ++ let buf = build_action(5, 0, &data); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ // ─── Security: Action type boundary values ──────────────────────── ++ ++ #[test] ++ fn test_validate_action_type_7_rejected() { ++ let buf = build_action(7, 0, &[0u8; 8]); // gap value ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_action_type_8_rejected() { ++ let buf = build_action(8, 0, &[0u8; 8]); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_action_type_9_rejected() { ++ let buf = build_action(9, 0, &[0u8; 8]); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_action_type_12_rejected() { ++ let buf = build_action(12, 0, &[0u8; 32]); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_action_type_255_rejected() { ++ let buf = build_action(255, 0, &[0u8; 8]); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_action_type_0_rejected() { ++ let buf = build_action(0, 0, &[0u8; 8]); ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ // ─── Security: MAX_ACTIONS limit ────────────────────────────────── ++ ++ #[test] ++ fn test_validate_max_actions_limit() { ++ let mut buf = Vec::new(); ++ for i in 0..=MAX_ACTIONS { ++ let mut prog = [0u8; 32]; ++ prog[0] = i as u8; ++ buf.extend_from_slice(&build_action(10, 0, &prog)); // ProgramWhitelist ++ } ++ // 17 actions should fail (MAX_ACTIONS = 16) ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++ ++ #[test] ++ fn test_validate_exactly_max_actions_ok() { ++ let mut buf = Vec::new(); ++ for i in 0..MAX_ACTIONS { ++ let mut prog = [0u8; 32]; ++ prog[0] = i as u8; ++ buf.extend_from_slice(&build_action(10, 0, &prog)); ++ } ++ // 16 actions should be fine ++ assert!(validate_actions_buffer(&buf).is_ok()); ++ } ++ ++ // ─── Security: Trailing bytes ───────────────────────────────────── ++ ++ #[test] ++ fn test_validate_trailing_bytes_rejected() { ++ let mut buf = build_action(3, 0, &500_000u64.to_le_bytes()); ++ buf.push(0xFF); // trailing garbage byte ++ // parse_actions should fail because the trailing byte doesn't form a valid header ++ assert!(validate_actions_buffer(&buf).is_err()); ++ } ++} +diff --git a/program/src/state/mod.rs b/program/src/state/mod.rs +index 22802b8..55ece4a 100644 +--- a/program/src/state/mod.rs ++++ b/program/src/state/mod.rs +@@ -1,3 +1,4 @@ ++pub mod action; + pub mod authority; + pub mod deferred; + pub mod session; +diff --git a/program/src/state/session.rs b/program/src/state/session.rs +index 4593bdf..655dbdb 100644 +--- a/program/src/state/session.rs ++++ b/program/src/state/session.rs +@@ -1,13 +1,15 @@ + use no_padding::NoPadding; + use pinocchio::pubkey::Pubkey; + ++/// Size of the fixed session header (excluding actions). ++pub const SESSION_HEADER_SIZE: usize = 80; ++ + #[repr(C, align(8))] + #[derive(NoPadding)] + /// Ephemeral Session Account. + /// + /// Represents a temporary delegated authority with an expiration time. +-// Removed duplicate attribute +-#[derive(NoPadding)] ++/// Optional actions may follow the 80-byte header as a flat byte buffer. + pub struct SessionAccount { + /// Account discriminator (must be `3` for Session). + pub discriminator: u8, // 1 +@@ -24,3 +26,20 @@ pub struct SessionAccount { + /// Absolute slot height when this session expires. + pub expires_at: u64, // 8 + } ++ ++/// Returns true if the session account data contains actions after the header. ++#[inline] ++pub fn has_actions(session_data: &[u8]) -> bool { ++ session_data.len() > SESSION_HEADER_SIZE ++} ++ ++/// Returns the actions buffer slice (bytes after the 80-byte header). ++/// Returns empty slice if no actions. ++#[inline] ++pub fn actions_slice(session_data: &[u8]) -> &[u8] { ++ if session_data.len() > SESSION_HEADER_SIZE { ++ &session_data[SESSION_HEADER_SIZE..] ++ } else { ++ &[] ++ } ++} diff --git a/docs/audit/program-src.diff.stat b/docs/audit/program-src.diff.stat new file mode 100644 index 0000000..ccb2184 --- /dev/null +++ b/docs/audit/program-src.diff.stat @@ -0,0 +1,23 @@ + program/Cargo.toml | 10 + + program/idl.json | 48 +- + program/src/auth/secp256r1/introspection.rs | 256 ++++- + program/src/auth/secp256r1/mod.rs | 214 ++-- + program/src/auth/secp256r1/webauthn.rs | 623 ++++++++-- + program/src/compact.rs | 152 ++- + program/src/error.rs | 15 + + program/src/instruction.rs | 73 +- + program/src/lib.rs | 17 + + program/src/processor/create_session.rs | 278 ++++- + program/src/processor/create_wallet.rs | 72 +- + program/src/processor/execute.rs | 272 +++-- + program/src/processor/execute_actions.rs | 1644 +++++++++++++++++++++++++++ + program/src/processor/execute_deferred.rs | 83 +- + program/src/processor/manage_authority.rs | 54 +- + program/src/processor/mod.rs | 1 + + program/src/processor/revoke_session.rs | 5 + + program/src/processor/transfer_ownership.rs | 74 +- + program/src/state/action.rs | 697 ++++++++++++ + program/src/state/mod.rs | 1 + + program/src/state/session.rs | 23 +- + 21 files changed, 4168 insertions(+), 444 deletions(-) + 21 files changed, 4168 insertions(+), 444 deletions(-) diff --git a/docs/audit/upstream-parity.txt b/docs/audit/upstream-parity.txt new file mode 100644 index 0000000..cfd7d00 --- /dev/null +++ b/docs/audit/upstream-parity.txt @@ -0,0 +1,26 @@ +Generating byte-identity report vs upstream lazorkit-protocol... + +For each file changed in program-v2 between audit-baseline-2026-02-accretion +and audit-pending-v1, indicate whether the post-state is byte-identical to +the equivalent file in lazorkit-protocol (which Accretion has audited as the +shared upstream). Identical → audit can defer to existing review. + + ✓ identical: program/src/auth/secp256r1/mod.rs + ✓ identical: program/src/auth/secp256r1/webauthn.rs + ✓ identical: program/src/auth/secp256r1/introspection.rs + ✓ identical: program/src/compact.rs + ⚠ differs (14 diff lines): program/src/lib.rs vs program/src/lib.rs + ⚠ differs (23 diff lines): program/src/error.rs vs program/src/error.rs + ⚠ differs (68 diff lines): program/src/instruction.rs vs program/src/instruction.rs + ✓ identical: program/src/state/action.rs + ✓ identical: program/src/state/session.rs + ⚠ differs (12 diff lines): program/src/state/mod.rs vs program/src/state/mod.rs + ⚠ differs (26 diff lines): program/src/processor/mod.rs vs program/src/processor/mod.rs + ✓ identical: program/src/processor/create_session.rs + ✓ identical: program/src/processor/create_wallet.rs + ⚠ differs (4 diff lines): program/src/processor/execute.rs vs program/src/processor/execute/immediate.rs + ✓ identical: program/src/processor/execute_actions.rs + ✓ identical: program/src/processor/execute_deferred.rs + ✓ identical: program/src/processor/manage_authority.rs + ✓ identical: program/src/processor/revoke_session.rs + ✓ identical: program/src/processor/transfer_ownership.rs From 770ad2d51f66e04f4a85f3e1665a844269012c03 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 18:45:53 +0700 Subject: [PATCH 12/13] docs(changelog): record P5 (auth + processor port) and P6 (audit prep) entries --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 174077c..68008c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- End-to-end vitest tests for session-action enforcement (`tests-sdk/tests/12-actions.test.ts`, 9 cases): `programWhitelist` allow + reject (3021), `programBlacklist` allow + reject (3022), `solMaxPerTx` allow at-cap + reject over-cap (3023), `solLimit` lifetime budget exhaustion (3024), and combined-rules enforcement. Runs against a live `solana-test-validator` with the foundation binary loaded and uses `@lazorkit/sdk-legacy`'s `Actions` builder to dogfood the full encode → on-chain enforce path. +- `docs/audit/` artifacts for an Accretion delta-audit follow-up: `DELTA_BRIEF.md` summarises the changes from the previous audited baseline by phase with explicit audit asks; `program-src.diff` is the full unified diff of `program/`; `program-src.diff.stat` is a per-file changed-line summary; `upstream-parity.txt` reports byte-identity vs the already-audited `lazorkit-protocol` per file (13/19 changed files identical). +- Local git tags `audit-baseline-2026-02-accretion` (previous Accretion-audited state, commit `d1eaaeb`) and `audit-pending-v1` (the current consolidated state ready for delta review). - Session action permissions: 8 immutable permission rules attachable at session creation — `SolLimit`, `SolRecurringLimit`, `SolMaxPerTx`, `TokenLimit`, `TokenRecurringLimit`, `TokenMaxPerTx`, `ProgramWhitelist`, `ProgramBlacklist`. Action discriminators (1, 2, 3, 4, 5, 6, 10, 11) and the 11-byte header layout match `lazorkit-protocol` so the unified SDK can encode actions identically for both builds. - `SessionAccount` is now variable-size: a session can carry a trailing action buffer (max 16 actions, ≤ 2048 bytes) validated at creation time. - `CreateSession` instruction data accepts the new `[actions_len: u16][actions: N]` extension after the legacy 40-byte args; old 40-byte clients continue to work via the legacy parser branch. @@ -67,6 +70,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- Secp256r1 auth payload format: replaces the older `typeAndFlags` byte at `auth_payload[13]` with full raw `clientDataJSON` embedded in the payload. The on-chain auth verifier now parses the JSON directly rather than reconstructing it from `typeAndFlags + rpId`. Aligns with `lazorkit-protocol` byte-for-byte and is required for binary-swap compatibility at the shared mainnet slot. +- Secp256r1 authority on-chain layout: replaces the previously stored variable-length raw `rpId` with a precomputed 32-byte `rpIdHash` (SHA-256 digest computed at registration). New layout: `header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145 bytes`. Saves one `sol_sha256` syscall per `Execute`. Existing wallets created on the upstream commercial binary remain readable after binary swap. +- Shank IDL declarations on the `ProgramIx` enum (account metadata: `writable` modifiers, account positions, descriptions) resynced with `lazorkit-protocol`. Five fee-related variants (disc 10–14: `InitializeProtocol`, `UpdateProtocol`, `RegisterPayer`, `WithdrawTreasury`, `InitializeTreasuryShard`) stripped — `program-v2` keeps disc 0–9 only. Runtime not affected (`@lazorkit/sdk-legacy` uses hand-written builders rather than the generated IDL). - SDK API: unified all methods via discriminated unions (breaking: removed `createWalletEd25519`, `createWalletSecp256r1`, `addAuthoritySecp256r1`, `removeAuthoritySecp256r1`, `executeEd25519`, `executeSecp256r1`, `executeSession`, `createSessionSecp256r1`, `transferOwnershipSecp256r1`, `authorizeSecp256r1`) - SDK API: all methods now return `{ instructions: TransactionInstruction[]; ...extraPdas }` consistently - SDK API: `createSession` now takes `sessionKey: PublicKey` instead of `Uint8Array` @@ -87,6 +93,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- `tests-sdk` integration tests now pass `PROGRAM_ID` explicitly to the `LazorKitClient` constructor. `@lazorkit/sdk-legacy`'s URL-based program-ID inference defaulted localhost to the commercial devnet ID (`4h3X…`); against a local validator loading the foundation binary at the keypair's pubkey this caused all txs to fail with "Attempt to load a program that does not exist". `tests/common.ts` now resolves `PROGRAM_ID` from (1) `PROGRAM_ID` env override, (2) the keypair file at `target/deploy/lazorkit_program-keypair.json`, or (3) the foundation devnet fallback `FLb7…`. +- `tests-sdk/tests/08-deferred.test.ts` builds the `Authorize` `signed_payload` as `instructions_hash || accounts_hash || expiry_offset (u16 LE)` to match what the on-chain verifier hashes. The test code was missing the 2-byte expiry buffer at all 6 sign sites, causing all 7 deferred tests to fail with `InvalidMessageHash` (3005). After the fix, all 65 vitest E2E tests pass against a live validator. - Authorize signed payload now includes `expiry_offset` (66 bytes total), preventing relayers from modifying the expiry window - `sol_assert_bytes_eq` now uses the `len` parameter instead of `left.len()` (latent OOB read on-chain) - `reclaim_deferred` uses `checked_add` for lamports (consistent with `execute_deferred` and `manage_authority`) From 1abb98ccc1292bdfb5e670b5f0f9c53d35d606bb Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 18:50:26 +0700 Subject: [PATCH 13/13] chore: remove Solana Foundation references from content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cite the audit firm as 'Accretion' / 'Accretion Labs' only — README, SECURITY policy, on-chain security_txt, and the audit delta brief. Audit PDF filename retained (already an immutable artifact). --- README.md | 2 +- SECURITY.md | 2 +- docs/audit/DELTA_BRIEF.md | 2 +- docs/audit/program-src.diff | 4 ++-- program/src/lib.rs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c9f9219..406abbb 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for full development workflow. ## Security -LazorKit V2 has been audited by **Accretion** (Solana Foundation funded). +LazorKit V2 has been audited by **Accretion**. **Status**: 17/17 security issues resolved diff --git a/SECURITY.md b/SECURITY.md index e672c44..0fc1e5b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,7 +31,7 @@ The following are out of scope: ## Audit Status -LazorKit V2 has been audited by Accretion (Solana Foundation funded). +LazorKit V2 has been audited by Accretion. **Status**: 17/17 security issues resolved diff --git a/docs/audit/DELTA_BRIEF.md b/docs/audit/DELTA_BRIEF.md index d568176..5ee0e52 100644 --- a/docs/audit/DELTA_BRIEF.md +++ b/docs/audit/DELTA_BRIEF.md @@ -1,7 +1,7 @@ # Accretion Audit Delta Brief — `program-v2` `audit-baseline-2026-02-accretion` → `audit-pending-v1` **Repository:** `lazor-kit/program-v2` -**Previous audit:** Accretion Labs, Solana Foundation, February 2026, A26SFR1 +**Previous audit:** Accretion Labs, February 2026, A26SFR1 ([audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf](../../audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf)) **Previous baseline tag:** `audit-baseline-2026-02-accretion` → commit `d1eaaeb` (Merge PR #49 fix/audit-hardening, 17/17 findings resolved) **Delta tag:** `audit-pending-v1` → commit `9c97fe2` diff --git a/docs/audit/program-src.diff b/docs/audit/program-src.diff index 4eef8f5..2be1ef1 100644 --- a/docs/audit/program-src.diff +++ b/docs/audit/program-src.diff @@ -1850,7 +1850,7 @@ index 21742cc..0c970f0 100644 new_type: u8, new_pubkey: [u8; 33], diff --git a/program/src/lib.rs b/program/src/lib.rs -index aad119c..2a41038 100644 +index aad119c..0017e02 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -1,5 +1,22 @@ @@ -1870,7 +1870,7 @@ index aad119c..2a41038 100644 + source_code: "https://github.com/lazor-kit/program-v2", + source_revision: default_env!("GITHUB_SHA", ""), + source_release: default_env!("GITHUB_REF_NAME", ""), -+ auditors: "Accretion Labs (Solana Foundation) — https://github.com/lazor-kit/program-v2/blob/main/audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf" ++ auditors: "Accretion Labs — https://github.com/lazor-kit/program-v2/blob/main/audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf" +} + pub mod auth; diff --git a/program/src/lib.rs b/program/src/lib.rs index 2a41038..0017e02 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -14,7 +14,7 @@ security_txt! { source_code: "https://github.com/lazor-kit/program-v2", source_revision: default_env!("GITHUB_SHA", ""), source_release: default_env!("GITHUB_REF_NAME", ""), - auditors: "Accretion Labs (Solana Foundation) — https://github.com/lazor-kit/program-v2/blob/main/audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf" + auditors: "Accretion Labs — https://github.com/lazor-kit/program-v2/blob/main/audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf" } pub mod auth;