From 9f2c5778b6003985fa8455af4d8b56aa3b1b1136 Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 13:14:56 +0200 Subject: [PATCH 01/14] feat(sdk): hashchain branching primitives (signChain/signBranches/status) --- sdk/ts/src/branching.ts | 195 +++++++++++++++++++++++++++++ sdk/ts/test/branching.unit.test.ts | 142 +++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 sdk/ts/src/branching.ts create mode 100644 sdk/ts/test/branching.unit.test.ts diff --git a/sdk/ts/src/branching.ts b/sdk/ts/src/branching.ts new file mode 100644 index 0000000..c4c0148 --- /dev/null +++ b/sdk/ts/src/branching.ts @@ -0,0 +1,195 @@ +/** + * Branching: ergonomic helpers over Vector's forward-secure hashchain. + * + * Each nonce is `SHA256(pre || current_nonce || identity || post)`, so a + * state binds to the exact transaction that produced it. From any state you + * can pre-sign several alternative transactions ("branches"); whichever + * lands first advances the chain onto its branch and atomically invalidates + * the siblings (they were signed against a nonce that no longer exists). You + * therefore "sign N full chains and execute one" — the design forces you to + * be explicit about the exact sequence(s) of events that may occur. + */ +import { Address, Connection, TransactionInstruction } from "@solana/web3.js"; + +import { Scheme, fetchVectorAccount } from "./scheme.js"; +import { + ED25519, + ed25519Identity, + signAdvanceInstructionEd25519, +} from "./schemes/ed25519.js"; +import { advanceVectorDigest } from "./digest.js"; + +/** Per-scheme signer for a single chain (one key / identity). */ +export interface ChainSigner { + scheme: Scheme; + identity: Uint8Array; + /** Sign an advance over `(nonce, pre, post)` and return the advance ix. */ + sign( + nonce: Uint8Array, + pre: TransactionInstruction[], + post: TransactionInstruction[], + feePayer?: Address + ): TransactionInstruction; +} + +/** Ed25519 chain signer bound to a 32-byte private-key seed. */ +export function ed25519ChainSigner(signingKey: Uint8Array): ChainSigner { + return { + scheme: ED25519, + identity: ed25519Identity(signingKey), + sign: (nonce, pre, post, feePayer) => + signAdvanceInstructionEd25519(signingKey, nonce, pre, post, feePayer), + }; +} + +/** One step in a chain: instructions placed around the advance. */ +export interface BranchStep { + /** Top-level ixs before the advance (committed to by the digest). */ + pre?: TransactionInstruction[]; + /** Top-level ixs after the advance, e.g. the passthrough. */ + post?: TransactionInstruction[]; +} + +/** A signed step: the advance, the exact ixs it commits to, and its nonces. */ +export interface SignedStep { + index: number; + /** Nonce this step is signed against. */ + nonce: Uint8Array; + /** Nonce the chain holds after this step executes. */ + nextNonce: Uint8Array; + advanceIx: TransactionInstruction; + pre: TransactionInstruction[]; + post: TransactionInstruction[]; +} + +/** + * Pre-sign an ordered chain of steps starting at `startNonce`. Step i is + * signed against the nonce produced by step i-1, so the steps can ONLY + * execute in order (skipping or reordering changes the recomputed digest and + * fails verification). Broadcast each step as a transaction laid out exactly + * `[...pre, advanceIx, ...post]`. + */ +export function signChain( + signer: ChainSigner, + startNonce: Uint8Array, + steps: BranchStep[], + feePayer?: Address +): SignedStep[] { + const out: SignedStep[] = []; + let nonce = startNonce; + steps.forEach((step, index) => { + const pre = step.pre ?? []; + const post = step.post ?? []; + const advanceIx = signer.sign(nonce, pre, post, feePayer); + const nextNonce = advanceVectorDigest( + signer.scheme, + nonce, + signer.identity, + pre, + post, + feePayer + ); + out.push({ index, nonce, nextNonce, advanceIx, pre, post }); + nonce = nextNonce; + }); + return out; +} + +/** A labelled alternative: a full chain pre-signed from a shared parent. */ +export interface Branch { + label: string; + /** Parent nonce all branches diverge from. */ + head: Uint8Array; + steps: SignedStep[]; +} + +/** + * Pre-sign several alternative chains from one `parentNonce`. All branch + * heads are signed against the same parent, so executing any branch's first + * step consumes the parent nonce and atomically orphans every sibling. This + * is the "sign N full chains, execute one" primitive. + */ +export function signBranches( + signer: ChainSigner, + parentNonce: Uint8Array, + branches: { label: string; steps: BranchStep[] }[], + feePayer?: Address +): Branch[] { + return branches.map((b) => ({ + label: b.label, + head: parentNonce, + steps: signChain(signer, parentNonce, b.steps, feePayer), + })); +} + +/** Where a pre-signed chain stands relative to the current on-chain nonce. */ +export type ChainStatus = + | { state: "pending"; nextStepIndex: number } + | { state: "completed" } + | { state: "orphaned" }; + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +/** + * Given a pre-signed chain and the current on-chain nonce, report whether + * the chain is mid-flight (and which step is next), fully executed, or + * orphaned (the nonce is on a sibling branch / unrelated state). + */ +export function resolveChainStatus( + steps: SignedStep[], + currentNonce: Uint8Array +): ChainStatus { + for (let i = 0; i < steps.length; i++) { + if (bytesEqual(steps[i].nonce, currentNonce)) { + return { state: "pending", nextStepIndex: i }; + } + } + if ( + steps.length > 0 && + bytesEqual(steps[steps.length - 1].nextNonce, currentNonce) + ) { + return { state: "completed" }; + } + return { state: "orphaned" }; +} + +/** + * Which branch the chain has committed to, given the current on-chain nonce. + * A branch is "won" once it is pending past its head or completed; returns + * null while still at the shared parent (no branch chosen yet) or if the + * nonce matches no branch. + */ +export function whichBranchWon( + branches: Branch[], + currentNonce: Uint8Array +): Branch | null { + for (const b of branches) { + const status = resolveChainStatus(b.steps, currentNonce); + if (status.state === "completed") return b; + if (status.state === "pending" && status.nextStepIndex > 0) return b; + } + return null; +} + +/** Fetch the chain's on-chain nonce and resolve its status. */ +export async function fetchChainStatus( + connection: Connection, + signer: ChainSigner, + steps: SignedStep[] +): Promise { + try { + const account = await fetchVectorAccount( + connection, + signer.scheme, + signer.identity + ); + return resolveChainStatus(steps, account.nonce); + } catch { + return { state: "orphaned" }; + } +} diff --git a/sdk/ts/test/branching.unit.test.ts b/sdk/ts/test/branching.unit.test.ts new file mode 100644 index 0000000..f2e2251 --- /dev/null +++ b/sdk/ts/test/branching.unit.test.ts @@ -0,0 +1,142 @@ +import { describe, test, expect } from "vitest"; +import { Address, SystemProgram } from "@solana/web3.js"; +import type { Connection } from "@solana/web3.js"; +import { + ed25519ChainSigner, + signChain, + signBranches, + resolveChainStatus, + whichBranchWon, + fetchChainStatus, +} from "../src/branching.js"; +import { + ED25519, + ed25519Identity, + ADVANCE_DISCRIMINATOR, + advanceVectorDigest, + serializeVectorAccountHeader, +} from "../src/index.js"; + +const KEY = new Uint8Array(32); +KEY[31] = 0x07; +const PAY = new Address("11111111111111111111111111111112"); +const tx = (lamports: number) => ({ + post: [SystemProgram.transfer({ fromPubkey: PAY, toPubkey: PAY, lamports })], +}); + +describe("ed25519ChainSigner", () => { + test("exposes scheme + identity and signs an advance", () => { + const signer = ed25519ChainSigner(KEY); + expect(signer.scheme.programId.toBase58()).toBe(ED25519.programId.toBase58()); + expect(Buffer.from(signer.identity)).toEqual(Buffer.from(ed25519Identity(KEY))); + + const nonce = new Uint8Array(32).fill(1); + const ix = signer.sign(nonce, [], []); + expect(ix.data[0]).toBe(ADVANCE_DISCRIMINATOR); + expect(ix.data.length).toBe(1 + ED25519.signatureLen); + }); +}); + +describe("signChain", () => { + const signer = ed25519ChainSigner(KEY); + const start = new Uint8Array(32).fill(2); + + test("chains each step's nonce from the previous nextNonce", () => { + const chain = signChain(signer, start, [{}, {}, {}]); + expect(chain.length).toBe(3); + expect(Buffer.from(chain[0].nonce)).toEqual(Buffer.from(start)); + chain.forEach((s) => { + const expected = advanceVectorDigest( + ED25519, + s.nonce, + signer.identity, + s.pre, + s.post + ); + expect(Buffer.from(s.nextNonce)).toEqual(Buffer.from(expected)); + }); + expect(Buffer.from(chain[1].nonce)).toEqual(Buffer.from(chain[0].nextNonce)); + expect(Buffer.from(chain[2].nonce)).toEqual(Buffer.from(chain[1].nextNonce)); + }); +}); + +describe("signBranches", () => { + const signer = ed25519ChainSigner(KEY); + const parent = new Uint8Array(32).fill(3); + + test("every branch head is signed against the shared parent nonce", () => { + const branches = signBranches(signer, parent, [ + { label: "settle", steps: [tx(1)] }, + { label: "cancel", steps: [tx(2)] }, + ]); + expect(branches.map((b) => b.label)).toEqual(["settle", "cancel"]); + for (const b of branches) { + expect(Buffer.from(b.steps[0].nonce)).toEqual(Buffer.from(parent)); + } + expect(Buffer.from(branches[0].steps[0].nextNonce)).not.toEqual( + Buffer.from(branches[1].steps[0].nextNonce) + ); + }); +}); + +describe("resolveChainStatus", () => { + const signer = ed25519ChainSigner(KEY); + const start = new Uint8Array(32).fill(2); + const chain = signChain(signer, start, [{}, {}]); + + test("pending at the next unexecuted step", () => { + expect(resolveChainStatus(chain, chain[0].nonce)).toEqual({ + state: "pending", + nextStepIndex: 0, + }); + expect(resolveChainStatus(chain, chain[1].nonce)).toEqual({ + state: "pending", + nextStepIndex: 1, + }); + }); + + test("completed at the final nextNonce", () => { + expect(resolveChainStatus(chain, chain[1].nextNonce)).toEqual({ + state: "completed", + }); + }); + + test("orphaned when the on-chain nonce is off this chain", () => { + expect(resolveChainStatus(chain, new Uint8Array(32).fill(0xff))).toEqual({ + state: "orphaned", + }); + }); +}); + +describe("whichBranchWon", () => { + const signer = ed25519ChainSigner(KEY); + const parent = new Uint8Array(32).fill(3); + const branches = signBranches(signer, parent, [ + { label: "settle", steps: [tx(1)] }, + { label: "cancel", steps: [tx(2)] }, + ]); + + test("returns the live branch once one has executed", () => { + const afterSettle = branches[0].steps[0].nextNonce; + expect(whichBranchWon(branches, afterSettle)?.label).toBe("settle"); + }); + + test("returns null while still at the shared parent", () => { + expect(whichBranchWon(branches, parent)?.label).toBe(undefined); + }); +}); + +describe("fetchChainStatus", () => { + const signer = ed25519ChainSigner(KEY); + const chain = signChain(signer, new Uint8Array(32).fill(2), [{}]); + const conn = { + getAccountInfo: async () => ({ + data: serializeVectorAccountHeader({ nonce: chain[0].nonce, bump: 255 }), + }), + } as unknown as Connection; + + test("reads the on-chain nonce and resolves status", async () => { + const status = await fetchChainStatus(conn, signer, chain); + expect(status).toEqual({ state: "pending", nextStepIndex: 0 }); + }); +}); From 81c9907216f134cfce489a1fa9693f79660dcb6c Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 13:16:10 +0200 Subject: [PATCH 02/14] feat(sdk): lanes for independent parallel workstreams (secondary) --- sdk/ts/src/lanes.ts | 60 ++++++++++++++++++++++++++++++++++ sdk/ts/test/lanes.unit.test.ts | 33 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 sdk/ts/src/lanes.ts create mode 100644 sdk/ts/test/lanes.unit.test.ts diff --git a/sdk/ts/src/lanes.ts b/sdk/ts/src/lanes.ts new file mode 100644 index 0000000..2924033 --- /dev/null +++ b/sdk/ts/src/lanes.ts @@ -0,0 +1,60 @@ +/** + * Lanes (secondary): independent parallel workstreams for one authority. + * The PDA seeds are identity-only, so independent chains require independent + * identities — derived deterministically as sub-keys (HKDF-SHA256) from a + * master seed. Use this ONLY for non-exclusive parallelism (A-and-B); for + * ordered or mutually-exclusive flows use `branching.ts`. + */ +import { hkdfSync } from "crypto"; +import { Address } from "@solana/web3.js"; + +import { findVectorPda } from "./scheme.js"; +import { ED25519, ed25519Identity } from "./schemes/ed25519.js"; +import { ChainSigner, ed25519ChainSigner } from "./branching.js"; + +export const LANE_KDF_SALT = new TextEncoder().encode("vector-lane-kdf-v1"); + +export interface Lane { + index: number; + signingKey: Uint8Array; + identity: Uint8Array; + pda: Address; + bump: number; +} + +/** Deterministic 32-byte child seed for one lane (HKDF-SHA256). */ +export function deriveLaneSeed( + masterSeed: Uint8Array, + schemeName: string, + laneIndex: number +): Uint8Array { + if (!Number.isInteger(laneIndex) || laneIndex < 0) { + throw new Error(`laneIndex must be a non-negative integer, got ${laneIndex}`); + } + const info = new TextEncoder().encode(`vector-lane:${schemeName}:${laneIndex}`); + return new Uint8Array(hkdfSync("sha256", masterSeed, LANE_KDF_SALT, info, 32)); +} + +/** Derive one Ed25519 lane (sub-key → identity → PDA). */ +export function deriveEd25519Lane(masterSeed: Uint8Array, laneIndex: number): Lane { + const signingKey = deriveLaneSeed(masterSeed, "ed25519", laneIndex); + const identity = ed25519Identity(signingKey); + const [pda, bump] = findVectorPda(ED25519, identity); + return { index: laneIndex, signingKey, identity, pda, bump }; +} + +/** Derive `count` consecutive Ed25519 lanes from `start` (default 0). */ +export function deriveEd25519LaneSet( + masterSeed: Uint8Array, + count: number, + start = 0 +): Lane[] { + return Array.from({ length: count }, (_, i) => + deriveEd25519Lane(masterSeed, start + i) + ); +} + +/** A `ChainSigner` for a lane — run any branching helper on it. */ +export function laneChainSigner(lane: Lane): ChainSigner { + return ed25519ChainSigner(lane.signingKey); +} diff --git a/sdk/ts/test/lanes.unit.test.ts b/sdk/ts/test/lanes.unit.test.ts new file mode 100644 index 0000000..5f55add --- /dev/null +++ b/sdk/ts/test/lanes.unit.test.ts @@ -0,0 +1,33 @@ +import { describe, test, expect } from "vitest"; +import { deriveLaneSeed, deriveEd25519Lane, laneChainSigner } from "../src/lanes.js"; +import { ED25519, ed25519Identity, findVectorPda } from "../src/index.js"; + +const MASTER = new Uint8Array(32).fill(0x5a); + +describe("lanes", () => { + test("deriveLaneSeed is deterministic and index-distinct", () => { + expect(Buffer.from(deriveLaneSeed(MASTER, "ed25519", 0))).toEqual( + Buffer.from(deriveLaneSeed(MASTER, "ed25519", 0)) + ); + expect(Buffer.from(deriveLaneSeed(MASTER, "ed25519", 0))).not.toEqual( + Buffer.from(deriveLaneSeed(MASTER, "ed25519", 1)) + ); + expect(() => deriveLaneSeed(MASTER, "ed25519", -1)).toThrow(); + }); + + test("each lane has a distinct identity + PDA matching findVectorPda", () => { + const a = deriveEd25519Lane(MASTER, 0); + const b = deriveEd25519Lane(MASTER, 1); + expect(a.pda.toBase58()).not.toBe(b.pda.toBase58()); + const [pda, bump] = findVectorPda(ED25519, a.identity); + expect(a.pda.toBase58()).toBe(pda.toBase58()); + expect(a.bump).toBe(bump); + expect(Buffer.from(a.identity)).toEqual(Buffer.from(ed25519Identity(a.signingKey))); + }); + + test("laneChainSigner yields a ChainSigner bound to the lane key", () => { + const lane = deriveEd25519Lane(MASTER, 2); + const signer = laneChainSigner(lane); + expect(Buffer.from(signer.identity)).toEqual(Buffer.from(lane.identity)); + }); +}); From d79ee49616aac8b6d4945d2bfe45052dc170a04a Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 13:18:43 +0200 Subject: [PATCH 03/14] feat(sdk): export branching+lanes from barrel and subpaths; HKDF via @noble/hashes --- package.json | 8 ++++++++ sdk/ts/src/index.ts | 3 +++ sdk/ts/src/lanes.ts | 5 +++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e4752c0..4b952b1 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,14 @@ "import": "./sdk/ts/dist/digest.js", "types": "./sdk/ts/dist/digest.d.ts" }, + "./branching": { + "import": "./sdk/ts/dist/branching.js", + "types": "./sdk/ts/dist/branching.d.ts" + }, + "./lanes": { + "import": "./sdk/ts/dist/lanes.js", + "types": "./sdk/ts/dist/lanes.d.ts" + }, "./ed25519": { "import": "./sdk/ts/dist/schemes/ed25519.js", "types": "./sdk/ts/dist/schemes/ed25519.d.ts" diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index e20fdf1..267756a 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -40,6 +40,9 @@ export * from "./scheme.js"; export * from "./instructions.js"; export * from "./digest.js"; +export * from "./branching.js"; +export * from "./lanes.js"; + export * from "./schemes/ed25519.js"; export * from "./schemes/eip191.js"; export * from "./schemes/secp256k1.js"; diff --git a/sdk/ts/src/lanes.ts b/sdk/ts/src/lanes.ts index 2924033..ce6817c 100644 --- a/sdk/ts/src/lanes.ts +++ b/sdk/ts/src/lanes.ts @@ -5,7 +5,8 @@ * master seed. Use this ONLY for non-exclusive parallelism (A-and-B); for * ordered or mutually-exclusive flows use `branching.ts`. */ -import { hkdfSync } from "crypto"; +import { hkdf } from "@noble/hashes/hkdf"; +import { sha256 } from "@noble/hashes/sha256"; import { Address } from "@solana/web3.js"; import { findVectorPda } from "./scheme.js"; @@ -32,7 +33,7 @@ export function deriveLaneSeed( throw new Error(`laneIndex must be a non-negative integer, got ${laneIndex}`); } const info = new TextEncoder().encode(`vector-lane:${schemeName}:${laneIndex}`); - return new Uint8Array(hkdfSync("sha256", masterSeed, LANE_KDF_SALT, info, 32)); + return hkdf(sha256, masterSeed, LANE_KDF_SALT, info, 32); } /** Derive one Ed25519 lane (sub-key → identity → PDA). */ From c8ade1d29fe94c88346bcda32e0f6cd854082d55 Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 13:21:55 +0200 Subject: [PATCH 04/14] =?UTF-8?q?test(sdk):=20integration=20=E2=80=94=20fo?= =?UTF-8?q?rward-secrecy,=20branch=20exclusivity,=20lane=20independence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/ts/test/branching.test.ts | 125 ++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 sdk/ts/test/branching.test.ts diff --git a/sdk/ts/test/branching.test.ts b/sdk/ts/test/branching.test.ts new file mode 100644 index 0000000..889d148 --- /dev/null +++ b/sdk/ts/test/branching.test.ts @@ -0,0 +1,125 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { + Connection, + Keypair, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { + ED25519, + fetchVectorAccount, + createInitializeEd25519, + ed25519ChainSigner, + signChain, + signBranches, + deriveEd25519LaneSet, + laneChainSigner, +} from "../src/index.js"; +import { RPC_URL, WS_URL, FEE_PAYER_SEED, sendTx } from "./helpers.js"; + +// Distinct keys so this suite never collides with the per-scheme tests. +const CHAIN_KEY = new Uint8Array(32); +CHAIN_KEY[31] = 0x42; +const LANE_MASTER = new Uint8Array(32).fill(0x5b); + +describe("vector-branching", () => { + let connection: Connection; + let feePayer: Keypair; + + beforeAll(async () => { + connection = new Connection(RPC_URL, { commitment: "confirmed", wsEndpoint: WS_URL }); + feePayer = await Keypair.fromSeed(FEE_PAYER_SEED); + }); + + afterAll(() => { + (connection as any)._rpcWebSocket?.close(); + }); + + test("ordered chain enforces sequence (forward-secrecy)", async () => { + const signer = ed25519ChainSigner(CHAIN_KEY); + await sendTx( + connection, + new Transaction().add(createInitializeEd25519(feePayer.address, signer.identity)), + [feePayer] + ); + const { nonce } = await fetchVectorAccount(connection, ED25519, signer.identity); + const chain = signChain(signer, nonce, [{}, {}], feePayer.address); + + const submit = (s: (typeof chain)[number]) => + sendTx(connection, new Transaction().add(...s.pre, s.advanceIx, ...s.post), [feePayer]); + + // Step 1 cannot land before step 0 (signed against a future nonce). + await expect(submit(chain[1])).rejects.toThrow(); + // In order: step 0 then step 1 both succeed. + await submit(chain[0]); + await submit(chain[1]); + + const after = await fetchVectorAccount(connection, ED25519, signer.identity); + expect(Buffer.from(after.nonce)).toEqual(Buffer.from(chain[1].nextNonce)); + }); + + test("branches are mutually exclusive (sign two, execute one)", async () => { + const key = new Uint8Array(32); + key[31] = 0x43; + const signer = ed25519ChainSigner(key); + await sendTx( + connection, + new Transaction().add(createInitializeEd25519(feePayer.address, signer.identity)), + [feePayer] + ); + const { nonce } = await fetchVectorAccount(connection, ED25519, signer.identity); + + // Two distinct branches from the same state (different post ixs). + const post = (lamports: number) => ({ + post: [ + SystemProgram.transfer({ + fromPubkey: feePayer.address, + toPubkey: feePayer.address, + lamports, + }), + ], + }); + const branches = signBranches( + signer, + nonce, + [ + { label: "settle", steps: [post(1)] }, + { label: "cancel", steps: [post(2)] }, + ], + feePayer.address + ); + + const submit = (b: (typeof branches)[number]) => + sendTx( + connection, + new Transaction().add(...b.steps[0].pre, b.steps[0].advanceIx, ...b.steps[0].post), + [feePayer] + ); + + // Execute "settle"; "cancel" is now orphaned (parent nonce consumed). + await submit(branches[0]); + await expect(submit(branches[1])).rejects.toThrow(); + }); + + test("lanes advance independently (non-exclusive parallelism)", async () => { + const lanes = deriveEd25519LaneSet(LANE_MASTER, 2); + await sendTx( + connection, + new Transaction().add( + ...lanes.map((l) => createInitializeEd25519(feePayer.address, l.identity)) + ), + [feePayer] + ); + + for (const lane of lanes) { + const signer = laneChainSigner(lane); + const { nonce } = await fetchVectorAccount(connection, ED25519, lane.identity); + const [step] = signChain(signer, nonce, [{}], feePayer.address); + await sendTx(connection, new Transaction().add(step.advanceIx), [feePayer]); + } + + const a = await fetchVectorAccount(connection, ED25519, lanes[0].identity); + const b = await fetchVectorAccount(connection, ED25519, lanes[1].identity); + expect(Buffer.from(a.nonce)).not.toEqual(Buffer.from(b.nonce)); + }); +}); From d96ec63f8c3becc808eaf4857283aed6cff76600 Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 13:59:38 +0200 Subject: [PATCH 05/14] refactor(sdk): unify branching+lanes behind a Vector facade (front door) --- package.json | 8 +- sdk/ts/src/branching.ts | 24 ++++ sdk/ts/src/index.ts | 3 +- sdk/ts/src/lanes.ts | 61 --------- sdk/ts/src/vector.ts | 211 ++++++++++++++++++++++++++++++++ sdk/ts/test/branching.test.ts | 125 ------------------- sdk/ts/test/lanes.unit.test.ts | 33 ----- sdk/ts/test/vector.test.ts | 92 ++++++++++++++ sdk/ts/test/vector.unit.test.ts | 99 +++++++++++++++ 9 files changed, 431 insertions(+), 225 deletions(-) delete mode 100644 sdk/ts/src/lanes.ts create mode 100644 sdk/ts/src/vector.ts delete mode 100644 sdk/ts/test/branching.test.ts delete mode 100644 sdk/ts/test/lanes.unit.test.ts create mode 100644 sdk/ts/test/vector.test.ts create mode 100644 sdk/ts/test/vector.unit.test.ts diff --git a/package.json b/package.json index 4b952b1..f83cdb3 100644 --- a/package.json +++ b/package.json @@ -22,14 +22,14 @@ "import": "./sdk/ts/dist/digest.js", "types": "./sdk/ts/dist/digest.d.ts" }, + "./vector": { + "import": "./sdk/ts/dist/vector.js", + "types": "./sdk/ts/dist/vector.d.ts" + }, "./branching": { "import": "./sdk/ts/dist/branching.js", "types": "./sdk/ts/dist/branching.d.ts" }, - "./lanes": { - "import": "./sdk/ts/dist/lanes.js", - "types": "./sdk/ts/dist/lanes.d.ts" - }, "./ed25519": { "import": "./sdk/ts/dist/schemes/ed25519.js", "types": "./sdk/ts/dist/schemes/ed25519.d.ts" diff --git a/sdk/ts/src/branching.ts b/sdk/ts/src/branching.ts index c4c0148..2ec36de 100644 --- a/sdk/ts/src/branching.ts +++ b/sdk/ts/src/branching.ts @@ -10,6 +10,8 @@ * be explicit about the exact sequence(s) of events that may occur. */ import { Address, Connection, TransactionInstruction } from "@solana/web3.js"; +import { hkdf } from "@noble/hashes/hkdf"; +import { sha256 } from "@noble/hashes/sha256"; import { Scheme, fetchVectorAccount } from "./scheme.js"; import { @@ -193,3 +195,25 @@ export async function fetchChainStatus( return { state: "orphaned" }; } } + +// ── Sub-key derivation (independent parallel chains) ────────────────── + +/** Domain-separation salt for sub-account derivation; bumping it re-derives. */ +export const LANE_KDF_SALT = new TextEncoder().encode("vector-lane-kdf-v1"); + +/** + * Deterministic 32-byte child seed for an independent sub-account, derived + * from a master seed via HKDF-SHA256 (domain-separated by scheme + index). + * The same master always yields the same stable, independent sub-keys. + */ +export function deriveLaneSeed( + masterSeed: Uint8Array, + schemeName: string, + index: number +): Uint8Array { + if (!Number.isInteger(index) || index < 0) { + throw new Error(`index must be a non-negative integer, got ${index}`); + } + const info = new TextEncoder().encode(`vector-lane:${schemeName}:${index}`); + return hkdf(sha256, masterSeed, LANE_KDF_SALT, info, 32); +} diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index 267756a..f6e3ab0 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -40,8 +40,7 @@ export * from "./scheme.js"; export * from "./instructions.js"; export * from "./digest.js"; -export * from "./branching.js"; -export * from "./lanes.js"; +export * from "./vector.js"; export * from "./schemes/ed25519.js"; export * from "./schemes/eip191.js"; diff --git a/sdk/ts/src/lanes.ts b/sdk/ts/src/lanes.ts deleted file mode 100644 index ce6817c..0000000 --- a/sdk/ts/src/lanes.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Lanes (secondary): independent parallel workstreams for one authority. - * The PDA seeds are identity-only, so independent chains require independent - * identities — derived deterministically as sub-keys (HKDF-SHA256) from a - * master seed. Use this ONLY for non-exclusive parallelism (A-and-B); for - * ordered or mutually-exclusive flows use `branching.ts`. - */ -import { hkdf } from "@noble/hashes/hkdf"; -import { sha256 } from "@noble/hashes/sha256"; -import { Address } from "@solana/web3.js"; - -import { findVectorPda } from "./scheme.js"; -import { ED25519, ed25519Identity } from "./schemes/ed25519.js"; -import { ChainSigner, ed25519ChainSigner } from "./branching.js"; - -export const LANE_KDF_SALT = new TextEncoder().encode("vector-lane-kdf-v1"); - -export interface Lane { - index: number; - signingKey: Uint8Array; - identity: Uint8Array; - pda: Address; - bump: number; -} - -/** Deterministic 32-byte child seed for one lane (HKDF-SHA256). */ -export function deriveLaneSeed( - masterSeed: Uint8Array, - schemeName: string, - laneIndex: number -): Uint8Array { - if (!Number.isInteger(laneIndex) || laneIndex < 0) { - throw new Error(`laneIndex must be a non-negative integer, got ${laneIndex}`); - } - const info = new TextEncoder().encode(`vector-lane:${schemeName}:${laneIndex}`); - return hkdf(sha256, masterSeed, LANE_KDF_SALT, info, 32); -} - -/** Derive one Ed25519 lane (sub-key → identity → PDA). */ -export function deriveEd25519Lane(masterSeed: Uint8Array, laneIndex: number): Lane { - const signingKey = deriveLaneSeed(masterSeed, "ed25519", laneIndex); - const identity = ed25519Identity(signingKey); - const [pda, bump] = findVectorPda(ED25519, identity); - return { index: laneIndex, signingKey, identity, pda, bump }; -} - -/** Derive `count` consecutive Ed25519 lanes from `start` (default 0). */ -export function deriveEd25519LaneSet( - masterSeed: Uint8Array, - count: number, - start = 0 -): Lane[] { - return Array.from({ length: count }, (_, i) => - deriveEd25519Lane(masterSeed, start + i) - ); -} - -/** A `ChainSigner` for a lane — run any branching helper on it. */ -export function laneChainSigner(lane: Lane): ChainSigner { - return ed25519ChainSigner(lane.signingKey); -} diff --git a/sdk/ts/src/vector.ts b/sdk/ts/src/vector.ts new file mode 100644 index 0000000..d5cca71 --- /dev/null +++ b/sdk/ts/src/vector.ts @@ -0,0 +1,211 @@ +/** + * `Vector` — the front door to the Vector SDK. + * + * A `Vector` is bound once to a signing key and computes its on-chain + * identity and PDA up front. You then authorize work in terms of plain + * Solana **instructions** (the CPIs you want executed under the PDA); the + * SDK wires the `advance` + `passthrough` and the transaction layout for you, + * so you never assemble those by hand. + * + * Three ways to authorize, all returning ready-to-broadcast {@link Artifact}s: + * + * - {@link Vector.authorize} — one op. + * - {@link Vector.chain} — an ordered, forward-secure sequence (each op can + * only execute after the previous one). + * - {@link Vector.branch} — mutually-exclusive alternatives ("sign N, execute + * one"); whichever lands first orphans the rest. + * + * For independent, non-exclusive parallel work (e.g. many simultaneous RFQ + * positions) derive sub-accounts with {@link Vector.derive} — each is another + * `Vector` with the same API. + * + * Signing is **synchronous and offline** (air-gapped friendly): you pass the + * nonce you signed against. The only networked call is {@link Vector.nonce}, + * which reads the current on-chain nonce. + * + * @example + * ```ts + * const v = Vector.ed25519(key, { feePayer }); + * const nonce = await v.nonce(connection); + * const art = v.authorize(nonce, withdrawIx); // op = Instruction | Instruction[] + * await sendAndConfirmTransaction(connection, art.transaction(), [feePayer]); + * ``` + */ +import { + Address, + Connection, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; + +import { Scheme, findVectorPda, fetchVectorAccount } from "./scheme.js"; +import { + ED25519, + ed25519Identity, + createInitializeEd25519, +} from "./schemes/ed25519.js"; +import { createPassthroughInstruction } from "./instructions.js"; +import { + ChainSigner, + ed25519ChainSigner, + signChain, + signBranches, + resolveChainStatus, + deriveLaneSeed, + BranchStep, + SignedStep, + ChainStatus, +} from "./branching.js"; + +export type { ChainStatus } from "./branching.js"; + +/** + * An op — the CPI(s) to run under the Vector PDA for one authorization. + * A single instruction or a list; an empty list is an **inert advance** + * (bumps the nonce with no side effects, i.e. a revocation). + */ +export type Op = TransactionInstruction | TransactionInstruction[]; + +/** A signed, broadcast-ready authorization with the advance/passthrough wired. */ +export interface Artifact { + /** Nonce this artifact is signed against; valid only while the PDA sits here. */ + nonce: Uint8Array; + /** Nonce the chain holds after this artifact executes. */ + nextNonce: Uint8Array; + /** Instructions to broadcast in order: `[advance]` or `[advance, passthrough]`. */ + instructions: TransactionInstruction[]; + /** A fresh `Transaction` of {@link instructions} (relayer adds blockhash + fee-payer sig). */ + transaction(): Transaction; +} + +export class Vector { + /** The signing scheme (program) this account uses. */ + readonly scheme: Scheme; + /** The client identity (Ed25519: the 32-byte public key). */ + readonly identity: Uint8Array; + /** The account's PDA — its on-chain address. */ + readonly pda: Address; + + private readonly key: Uint8Array; + private readonly signer: ChainSigner; + private readonly feePayer?: Address; + + private constructor( + scheme: Scheme, + key: Uint8Array, + signer: ChainSigner, + pda: Address, + feePayer?: Address + ) { + this.scheme = scheme; + this.key = key; + this.signer = signer; + this.identity = signer.identity; + this.pda = pda; + this.feePayer = feePayer; + } + + /** Bind a `Vector` to a 32-byte Ed25519 private-key seed. */ + static ed25519(key: Uint8Array, opts?: { feePayer?: Address }): Vector { + const signer = ed25519ChainSigner(key); + const [pda] = findVectorPda(ED25519, signer.identity); + return new Vector(ED25519, key, signer, pda, opts?.feePayer); + } + + /** The one-time `initialize` instruction that creates this account on-chain. */ + initialize(payer: Address): TransactionInstruction { + return createInitializeEd25519(payer, this.identity); + } + + /** Read the current on-chain nonce. The only networked call. */ + async nonce(connection: Connection): Promise { + const account = await fetchVectorAccount(connection, this.scheme, this.identity); + return account.nonce; + } + + /** + * Authorize a single op at `nonce`. An empty op (`[]`) is an inert advance — + * use it to **revoke** every artifact outstanding against `nonce`. + */ + authorize(nonce: Uint8Array, op: Op): Artifact { + return this.chain(nonce, [op])[0]; + } + + /** + * Authorize an ordered, forward-secure chain of ops starting at `nonce`. + * Op `i` is signed against the nonce op `i-1` produces, so the ops can only + * execute in order. Broadcast each returned artifact's `transaction()`. + */ + chain(nonce: Uint8Array, ops: Op[]): Artifact[] { + const steps = signChain( + this.signer, + nonce, + ops.map((op) => this.toStep(op)), + this.feePayer + ); + return steps.map((s) => this.toArtifact(s)); + } + + /** + * Authorize mutually-exclusive alternatives from one `nonce`. All share the + * same parent state, so executing any one orphans the rest atomically. + * Returns an artifact per label. + */ + branch(nonce: Uint8Array, named: Record): Record { + const labels = Object.keys(named); + const branches = signBranches( + this.signer, + nonce, + labels.map((label) => ({ label, steps: [this.toStep(named[label])] })), + this.feePayer + ); + const out: Record = {}; + branches.forEach((b, i) => { + out[labels[i]] = this.toArtifact(b.steps[0]); + }); + return out; + } + + /** + * Derive an independent sub-account (its own chain) from this account's key. + * Use for non-exclusive parallel work (e.g. one position per RFQ deal). The + * returned `Vector` has the same API and a distinct identity/PDA. + */ + derive(index: number): Vector { + const childSeed = deriveLaneSeed(this.key, "ed25519", index); + return Vector.ed25519(childSeed, { feePayer: this.feePayer }); + } + + /** + * Where a pre-signed chain stands given the current on-chain nonce: + * `pending` (and which op is next), `completed`, or `orphaned`. + */ + status(artifacts: Artifact[], currentNonce: Uint8Array): ChainStatus { + const steps: SignedStep[] = artifacts.map((a, index) => ({ + index, + nonce: a.nonce, + nextNonce: a.nextNonce, + advanceIx: a.instructions[0], + pre: [], + post: a.instructions.slice(1), + })); + return resolveChainStatus(steps, currentNonce); + } + + /** Wrap an op's CPIs in a passthrough (or nothing, for an inert advance). */ + private toStep(op: Op): BranchStep { + const ixs = Array.isArray(op) ? op : [op]; + if (ixs.length === 0) return {}; + return { post: [createPassthroughInstruction(this.scheme, this.identity, ixs)] }; + } + + private toArtifact(step: SignedStep): Artifact { + const instructions = [step.advanceIx, ...step.post]; + return { + nonce: step.nonce, + nextNonce: step.nextNonce, + instructions, + transaction: () => new Transaction().add(...instructions), + }; + } +} diff --git a/sdk/ts/test/branching.test.ts b/sdk/ts/test/branching.test.ts deleted file mode 100644 index 889d148..0000000 --- a/sdk/ts/test/branching.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { - Connection, - Keypair, - SystemProgram, - Transaction, -} from "@solana/web3.js"; -import { - ED25519, - fetchVectorAccount, - createInitializeEd25519, - ed25519ChainSigner, - signChain, - signBranches, - deriveEd25519LaneSet, - laneChainSigner, -} from "../src/index.js"; -import { RPC_URL, WS_URL, FEE_PAYER_SEED, sendTx } from "./helpers.js"; - -// Distinct keys so this suite never collides with the per-scheme tests. -const CHAIN_KEY = new Uint8Array(32); -CHAIN_KEY[31] = 0x42; -const LANE_MASTER = new Uint8Array(32).fill(0x5b); - -describe("vector-branching", () => { - let connection: Connection; - let feePayer: Keypair; - - beforeAll(async () => { - connection = new Connection(RPC_URL, { commitment: "confirmed", wsEndpoint: WS_URL }); - feePayer = await Keypair.fromSeed(FEE_PAYER_SEED); - }); - - afterAll(() => { - (connection as any)._rpcWebSocket?.close(); - }); - - test("ordered chain enforces sequence (forward-secrecy)", async () => { - const signer = ed25519ChainSigner(CHAIN_KEY); - await sendTx( - connection, - new Transaction().add(createInitializeEd25519(feePayer.address, signer.identity)), - [feePayer] - ); - const { nonce } = await fetchVectorAccount(connection, ED25519, signer.identity); - const chain = signChain(signer, nonce, [{}, {}], feePayer.address); - - const submit = (s: (typeof chain)[number]) => - sendTx(connection, new Transaction().add(...s.pre, s.advanceIx, ...s.post), [feePayer]); - - // Step 1 cannot land before step 0 (signed against a future nonce). - await expect(submit(chain[1])).rejects.toThrow(); - // In order: step 0 then step 1 both succeed. - await submit(chain[0]); - await submit(chain[1]); - - const after = await fetchVectorAccount(connection, ED25519, signer.identity); - expect(Buffer.from(after.nonce)).toEqual(Buffer.from(chain[1].nextNonce)); - }); - - test("branches are mutually exclusive (sign two, execute one)", async () => { - const key = new Uint8Array(32); - key[31] = 0x43; - const signer = ed25519ChainSigner(key); - await sendTx( - connection, - new Transaction().add(createInitializeEd25519(feePayer.address, signer.identity)), - [feePayer] - ); - const { nonce } = await fetchVectorAccount(connection, ED25519, signer.identity); - - // Two distinct branches from the same state (different post ixs). - const post = (lamports: number) => ({ - post: [ - SystemProgram.transfer({ - fromPubkey: feePayer.address, - toPubkey: feePayer.address, - lamports, - }), - ], - }); - const branches = signBranches( - signer, - nonce, - [ - { label: "settle", steps: [post(1)] }, - { label: "cancel", steps: [post(2)] }, - ], - feePayer.address - ); - - const submit = (b: (typeof branches)[number]) => - sendTx( - connection, - new Transaction().add(...b.steps[0].pre, b.steps[0].advanceIx, ...b.steps[0].post), - [feePayer] - ); - - // Execute "settle"; "cancel" is now orphaned (parent nonce consumed). - await submit(branches[0]); - await expect(submit(branches[1])).rejects.toThrow(); - }); - - test("lanes advance independently (non-exclusive parallelism)", async () => { - const lanes = deriveEd25519LaneSet(LANE_MASTER, 2); - await sendTx( - connection, - new Transaction().add( - ...lanes.map((l) => createInitializeEd25519(feePayer.address, l.identity)) - ), - [feePayer] - ); - - for (const lane of lanes) { - const signer = laneChainSigner(lane); - const { nonce } = await fetchVectorAccount(connection, ED25519, lane.identity); - const [step] = signChain(signer, nonce, [{}], feePayer.address); - await sendTx(connection, new Transaction().add(step.advanceIx), [feePayer]); - } - - const a = await fetchVectorAccount(connection, ED25519, lanes[0].identity); - const b = await fetchVectorAccount(connection, ED25519, lanes[1].identity); - expect(Buffer.from(a.nonce)).not.toEqual(Buffer.from(b.nonce)); - }); -}); diff --git a/sdk/ts/test/lanes.unit.test.ts b/sdk/ts/test/lanes.unit.test.ts deleted file mode 100644 index 5f55add..0000000 --- a/sdk/ts/test/lanes.unit.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, test, expect } from "vitest"; -import { deriveLaneSeed, deriveEd25519Lane, laneChainSigner } from "../src/lanes.js"; -import { ED25519, ed25519Identity, findVectorPda } from "../src/index.js"; - -const MASTER = new Uint8Array(32).fill(0x5a); - -describe("lanes", () => { - test("deriveLaneSeed is deterministic and index-distinct", () => { - expect(Buffer.from(deriveLaneSeed(MASTER, "ed25519", 0))).toEqual( - Buffer.from(deriveLaneSeed(MASTER, "ed25519", 0)) - ); - expect(Buffer.from(deriveLaneSeed(MASTER, "ed25519", 0))).not.toEqual( - Buffer.from(deriveLaneSeed(MASTER, "ed25519", 1)) - ); - expect(() => deriveLaneSeed(MASTER, "ed25519", -1)).toThrow(); - }); - - test("each lane has a distinct identity + PDA matching findVectorPda", () => { - const a = deriveEd25519Lane(MASTER, 0); - const b = deriveEd25519Lane(MASTER, 1); - expect(a.pda.toBase58()).not.toBe(b.pda.toBase58()); - const [pda, bump] = findVectorPda(ED25519, a.identity); - expect(a.pda.toBase58()).toBe(pda.toBase58()); - expect(a.bump).toBe(bump); - expect(Buffer.from(a.identity)).toEqual(Buffer.from(ed25519Identity(a.signingKey))); - }); - - test("laneChainSigner yields a ChainSigner bound to the lane key", () => { - const lane = deriveEd25519Lane(MASTER, 2); - const signer = laneChainSigner(lane); - expect(Buffer.from(signer.identity)).toEqual(Buffer.from(lane.identity)); - }); -}); diff --git a/sdk/ts/test/vector.test.ts b/sdk/ts/test/vector.test.ts new file mode 100644 index 0000000..9599521 --- /dev/null +++ b/sdk/ts/test/vector.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { Connection, Keypair, SystemProgram, Transaction } from "@solana/web3.js"; +import { Vector } from "../src/index.js"; +import { RPC_URL, WS_URL, FEE_PAYER_SEED, sendTx } from "./helpers.js"; + +// Distinct keys so this suite never collides with the per-scheme tests. +const CHAIN_KEY = new Uint8Array(32); +CHAIN_KEY[31] = 0x42; +const BRANCH_KEY = new Uint8Array(32); +BRANCH_KEY[31] = 0x43; +const ROOT_KEY = new Uint8Array(32); +ROOT_KEY[31] = 0x44; + +describe("Vector (front door, on-chain)", () => { + let connection: Connection; + let feePayer: Keypair; + + beforeAll(async () => { + connection = new Connection(RPC_URL, { commitment: "confirmed", wsEndpoint: WS_URL }); + feePayer = await Keypair.fromSeed(FEE_PAYER_SEED); + }); + + afterAll(() => { + (connection as any)._rpcWebSocket?.close(); + }); + + test("chain enforces order (forward-secrecy)", async () => { + const v = Vector.ed25519(CHAIN_KEY, { feePayer: feePayer.address }); + await sendTx( + connection, + new Transaction().add(v.initialize(feePayer.address)), + [feePayer] + ); + + const nonce = await v.nonce(connection); + const ops = v.chain(nonce, [[], []]); // two inert advances, ordered + + // Step 1 cannot land before step 0 (signed against a future nonce). + await expect(sendTx(connection, ops[1].transaction(), [feePayer])).rejects.toThrow(); + await sendTx(connection, ops[0].transaction(), [feePayer]); + await sendTx(connection, ops[1].transaction(), [feePayer]); + + expect(Buffer.from(await v.nonce(connection))).toEqual(Buffer.from(ops[1].nextNonce)); + }); + + test("branch is mutually exclusive (sign two, execute one)", async () => { + const v = Vector.ed25519(BRANCH_KEY, { feePayer: feePayer.address }); + await sendTx( + connection, + new Transaction().add(v.initialize(feePayer.address)), + [feePayer] + ); + + const nonce = await v.nonce(connection); + const pay = (lamports: number) => + SystemProgram.transfer({ + fromPubkey: feePayer.address, + toPubkey: feePayer.address, + lamports, + }); + const { settle, cancel } = v.branch(nonce, { settle: pay(1), cancel: pay(2) }); + + await sendTx(connection, settle.transaction(), [feePayer]); + await expect(sendTx(connection, cancel.transaction(), [feePayer])).rejects.toThrow(); + }); + + test("derived sub-accounts advance independently", async () => { + const root = Vector.ed25519(ROOT_KEY, { feePayer: feePayer.address }); + const a = root.derive(0); + const b = root.derive(1); + + // deterministic + distinct + expect(root.derive(0).pda.toBase58()).toBe(a.pda.toBase58()); + expect(a.pda.toBase58()).not.toBe(b.pda.toBase58()); + + await sendTx( + connection, + new Transaction().add( + a.initialize(feePayer.address), + b.initialize(feePayer.address) + ), + [feePayer] + ); + + const na = await a.nonce(connection); + const nb = await b.nonce(connection); + await sendTx(connection, a.authorize(na, []).transaction(), [feePayer]); // advance a only + + expect(Buffer.from(await a.nonce(connection))).not.toEqual(Buffer.from(na)); // a moved + expect(Buffer.from(await b.nonce(connection))).toEqual(Buffer.from(nb)); // b untouched + }); +}); diff --git a/sdk/ts/test/vector.unit.test.ts b/sdk/ts/test/vector.unit.test.ts new file mode 100644 index 0000000..f6239ab --- /dev/null +++ b/sdk/ts/test/vector.unit.test.ts @@ -0,0 +1,99 @@ +import { describe, test, expect } from "vitest"; +import { Address, SystemProgram, Transaction } from "@solana/web3.js"; +import { + Vector, + ED25519, + ed25519Identity, + findVectorPda, + ADVANCE_DISCRIMINATOR, + PASSTHROUGH_DISCRIMINATOR, + INITIALIZE_DISCRIMINATOR, +} from "../src/index.js"; + +const KEY = new Uint8Array(32); +KEY[31] = 0x11; +const PAY = new Address("11111111111111111111111111111112"); +const NONCE = new Uint8Array(32).fill(2); +const ix = (lamports: number) => + SystemProgram.transfer({ fromPubkey: PAY, toPubkey: PAY, lamports }); + +describe("Vector construction", () => { + test("binds identity + pda", () => { + const v = Vector.ed25519(KEY); + expect(Buffer.from(v.identity)).toEqual(Buffer.from(ed25519Identity(KEY))); + const [pda] = findVectorPda(ED25519, v.identity); + expect(v.pda.toBase58()).toBe(pda.toBase58()); + }); + + test("initialize targets the pda", () => { + const v = Vector.ed25519(KEY); + const i = v.initialize(PAY); + expect(i.data[0]).toBe(INITIALIZE_DISCRIMINATOR); + expect(i.keys[1].pubkey.toBase58()).toBe(v.pda.toBase58()); + }); +}); + +describe("authorize", () => { + const v = Vector.ed25519(KEY, { feePayer: PAY }); + + test("single op → [advance, passthrough] artifact", () => { + const art = v.authorize(NONCE, ix(1)); + expect(art.instructions.length).toBe(2); + expect(art.instructions[0].data[0]).toBe(ADVANCE_DISCRIMINATOR); + expect(art.instructions[1].data[0]).toBe(PASSTHROUGH_DISCRIMINATOR); + expect(Buffer.from(art.nonce)).toEqual(Buffer.from(NONCE)); + expect(art.transaction()).toBeInstanceOf(Transaction); + }); + + test("op accepts an array of CPIs in one passthrough", () => { + const art = v.authorize(NONCE, [ix(1), ix(2)]); + expect(art.instructions.length).toBe(2); // still [advance, one passthrough] + expect(art.instructions[1].data[0]).toBe(PASSTHROUGH_DISCRIMINATOR); + }); + + test("empty op is an inert advance (revocation): advance only", () => { + const art = v.authorize(NONCE, []); + expect(art.instructions.length).toBe(1); + expect(art.instructions[0].data[0]).toBe(ADVANCE_DISCRIMINATOR); + }); +}); + +describe("chain", () => { + const v = Vector.ed25519(KEY, { feePayer: PAY }); + test("ops are chained: each starts where the previous ended", () => { + const arts = v.chain(NONCE, [ix(1), ix(2), []]); + expect(arts.length).toBe(3); + expect(Buffer.from(arts[0].nonce)).toEqual(Buffer.from(NONCE)); + expect(Buffer.from(arts[1].nonce)).toEqual(Buffer.from(arts[0].nextNonce)); + expect(Buffer.from(arts[2].nonce)).toEqual(Buffer.from(arts[1].nextNonce)); + }); +}); + +describe("branch", () => { + const v = Vector.ed25519(KEY, { feePayer: PAY }); + test("alternatives share the parent nonce, diverge after", () => { + const { settle, cancel } = v.branch(NONCE, { settle: ix(10), cancel: [] }); + expect(Buffer.from(settle.nonce)).toEqual(Buffer.from(NONCE)); + expect(Buffer.from(cancel.nonce)).toEqual(Buffer.from(NONCE)); + expect(Buffer.from(settle.nextNonce)).not.toEqual(Buffer.from(cancel.nextNonce)); + }); +}); + +describe("derive", () => { + test("sub-accounts are deterministic and distinct", () => { + const v = Vector.ed25519(KEY); + expect(v.derive(0).pda.toBase58()).toBe(v.derive(0).pda.toBase58()); + expect(v.derive(0).pda.toBase58()).not.toBe(v.derive(1).pda.toBase58()); + expect(v.derive(0).pda.toBase58()).not.toBe(v.pda.toBase58()); + }); +}); + +describe("status", () => { + const v = Vector.ed25519(KEY, { feePayer: PAY }); + const arts = v.chain(NONCE, [ix(1), ix(2)]); + test("pending / completed / orphaned", () => { + expect(v.status(arts, arts[0].nonce)).toEqual({ state: "pending", nextStepIndex: 0 }); + expect(v.status(arts, arts[1].nextNonce)).toEqual({ state: "completed" }); + expect(v.status(arts, new Uint8Array(32).fill(0xff))).toEqual({ state: "orphaned" }); + }); +}); From 108ff6fa2329f11a1335b8af68a57b292e2d9af3 Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 13:59:38 +0200 Subject: [PATCH 06/14] docs(sdk): Vector SDK usage guide --- sdk/ts/README.md | 151 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 sdk/ts/README.md diff --git a/sdk/ts/README.md b/sdk/ts/README.md new file mode 100644 index 0000000..a21a3d8 --- /dev/null +++ b/sdk/ts/README.md @@ -0,0 +1,151 @@ +# Vector TypeScript SDK + +Offchain transaction signing for Solana that replaces durable-nonce workflows. +You pre-sign work against a Vector account's hashchain; a relayer broadcasts it +later with a fresh blockhash and its own fee payer. The signed payload can't be +altered in transit, and the chain is **forward-secure** — pre-signed steps can't +be reordered, skipped, or partially replayed. + +```bash +bun add vector-sdk # or npm / pnpm +``` + +## The front door: `Vector` + +A `Vector` is bound once to a signing key. It computes its identity and PDA up +front, and lets you authorize work in terms of plain Solana **instructions** — +the CPIs you want executed under the account's PDA. The SDK wires the `advance` ++ `passthrough` and the transaction layout for you. + +```ts +import { Vector } from "vector-sdk"; +import { Connection, Keypair, sendAndConfirmTransaction } from "@solana/web3.js"; + +const connection = new Connection("https://api.devnet.solana.com"); +const v = Vector.ed25519(signingKey, { feePayer: relayer.address }); + +v.identity; // 32-byte pubkey +v.pda; // the account's on-chain address +``` + +### One-time setup + +```ts +await sendAndConfirmTransaction( + connection, + new Transaction().add(v.initialize(payer.address)), + [payer] +); +``` + +### Authorize one action + +An **op** is the CPI(s) to run under the PDA — a single instruction or a list: + +```ts +const nonce = await v.nonce(connection); // read current state +const art = v.authorize(nonce, withdrawIx); // op: Instruction | Instruction[] + +// `art` is broadcast-ready; the relayer adds blockhash + signs as fee payer: +await sendAndConfirmTransaction(connection, art.transaction(), [relayer]); +``` + +An `Artifact` is what every builder returns: + +```ts +art.nonce // the nonce it's bound to (valid only while the PDA sits here) +art.nextNonce // the nonce after it executes +art.instructions // [advance] or [advance, passthrough] +art.transaction() // a fresh Transaction of those instructions +``` + +## Three strategies + +### Sequence — `chain` (ordered, forward-secure) + +Each op can only execute after the previous one. Reordering or skipping fails +verification, so a pre-signed batch executes in exactly the order you signed. + +```ts +const steps = v.chain(nonce, [opA, opB, opC]); // Artifact[] +// broadcast steps[0], then steps[1], then steps[2] +``` + +### Alternatives — `branch` (sign N, execute one) + +All branches share one parent state, so whichever lands first orphans the rest +**atomically** — ideal for "settle or cancel" and clean abandonment. + +```ts +const { settle, cancel } = v.branch(nonce, { + settle: paymentIx, + cancel: [], // empty op = inert advance (no side effects) +}); +await sendAndConfirmTransaction(connection, settle.transaction(), [relayer]); +// `cancel` is now permanently dead. +``` + +### Parallel — `derive` (independent sub-accounts) + +For non-exclusive parallel work (e.g. many simultaneous RFQ positions), derive +sub-accounts. Each is another `Vector` with the same API, its own chain, and a +distinct, deterministic identity/PDA. + +```ts +const position0 = v.derive(0); +const position1 = v.derive(1); +await position1.authorize(nonce1, []); // revoke just position 1 +``` + +## Revocation + +An empty op (`[]`) is an **inert advance**: it bumps the nonce with no side +effects, invalidating every artifact still outstanding against that nonce. +Unilateral and final. + +```ts +await sendAndConfirmTransaction( + connection, + v.authorize(await v.nonce(connection), []).transaction(), + [relayer] +); +``` + +## Checking validity + +```ts +const status = v.status(steps, await v.nonce(connection)); +// { state: "pending", nextStepIndex } | { state: "completed" } | { state: "orphaned" } +``` + +An artifact is broadcastable iff its chain is still at the nonce it was signed +against. + +## Air-gapped signing + +Signing is **synchronous and offline** — `authorize`/`chain`/`branch` take a +nonce and never touch the network. Only `nonce()` reads on-chain. For a cold +ceremony: read the nonce online, carry it to the air-gapped signer, sign there, +carry the artifact back out for the relayer to broadcast. + +```ts +// online watcher +const nonce = await v.nonce(connection); +// air-gapped signer (no connection) +const art = Vector.ed25519(coldKey, { feePayer }).authorize(nonce, withdrawIx); +``` + +## Low-level engine + +`Vector` is a facade over composable primitives in `vector-sdk/branching` +(`signChain`, `signBranches`, `resolveChainStatus`, `ChainSigner`, +`deriveLaneSeed`) and the instruction builders in `vector-sdk` (`createAdvanceInstruction`, +`createPassthroughInstruction`, `advanceVectorDigest`, …). Reach for these only +when you need control the facade doesn't expose (custom pre/post instructions, +non-Ed25519 schemes, bespoke digest handling). + +## Schemes + +`Vector.ed25519` is implemented today. The protocol also ships secp256k1, +EIP-191 (Ethereum wallets), and post-quantum Falcon-512 / Hawk-512 programs with +the identical instruction set; their facade constructors follow the same shape. From 6c2c1168f9e8b7597e181ca331f31dcfb8308b18 Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 14:06:08 +0200 Subject: [PATCH 07/14] feat(sdk): offline artifact inspection (decode, serialize, verify, render) + docs --- package.json | 4 + sdk/ts/README.md | 23 ++++ sdk/ts/src/index.ts | 1 + sdk/ts/src/inspect.ts | 219 +++++++++++++++++++++++++++++++ sdk/ts/src/vector.ts | 9 ++ sdk/ts/test/inspect.unit.test.ts | 69 ++++++++++ 6 files changed, 325 insertions(+) create mode 100644 sdk/ts/src/inspect.ts create mode 100644 sdk/ts/test/inspect.unit.test.ts diff --git a/package.json b/package.json index f83cdb3..bebb629 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,10 @@ "import": "./sdk/ts/dist/vector.js", "types": "./sdk/ts/dist/vector.d.ts" }, + "./inspect": { + "import": "./sdk/ts/dist/inspect.js", + "types": "./sdk/ts/dist/inspect.d.ts" + }, "./branching": { "import": "./sdk/ts/dist/branching.js", "types": "./sdk/ts/dist/branching.d.ts" diff --git a/sdk/ts/README.md b/sdk/ts/README.md index a21a3d8..e2260b3 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -121,6 +121,29 @@ const status = v.status(steps, await v.nonce(connection)); An artifact is broadcastable iff its chain is still at the nonce it was signed against. +## Inspecting & verifying artifacts + +Artifacts are transportable and self-describing — a counterparty or policy +engine can decode the intent and verify the signature **without a chain +connection**. + +```ts +import { + serializeArtifact, deserializeArtifact, + summarize, verifyArtifact, review, +} from "vector-sdk"; + +const wire = serializeArtifact(art); // deterministic JSON for transport +const a = deserializeArtifact(wire); + +summarize(a); // ["System transfer 5 lamports 1111… → 1111…", ...] +verifyArtifact(a); // true | false — recompute digest + check signature, offline +console.log(review(a)); // deterministic, human-readable block for sign-off +``` + +Unknown programs are rendered raw (program id + byte/account counts), never +silently hidden — so a reviewer always sees the full intent. + ## Air-gapped signing Signing is **synchronous and offline** — `authorize`/`chain`/`branch` take a diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index f6e3ab0..beee5e5 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -41,6 +41,7 @@ export * from "./instructions.js"; export * from "./digest.js"; export * from "./vector.js"; +export * from "./inspect.js"; export * from "./schemes/ed25519.js"; export * from "./schemes/eip191.js"; diff --git a/sdk/ts/src/inspect.ts b/sdk/ts/src/inspect.ts new file mode 100644 index 0000000..b823f35 --- /dev/null +++ b/sdk/ts/src/inspect.ts @@ -0,0 +1,219 @@ +/** + * Inspect, serialize, and offline-verify Vector {@link Artifact}s — so policy + * engines and air-gapped reviewers see *intent* instead of signing an opaque + * blob, and so a counterparty can transport an artifact and check it without a + * chain connection. + */ +import { Address, Transaction, TransactionInstruction } from "@solana/web3.js"; +import { ed25519 } from "@noble/curves/ed25519"; + +import { + Scheme, + readU16LE, + ADVANCE_DISCRIMINATOR, + PASSTHROUGH_DISCRIMINATOR, + CLOSE_DISCRIMINATOR, + WITHDRAW_DISCRIMINATOR, +} from "./scheme.js"; +import { advanceVectorDigest } from "./digest.js"; +import { ED25519 } from "./schemes/ed25519.js"; +import { SECP256K1 } from "./schemes/secp256k1.js"; +import { EIP191 } from "./schemes/eip191.js"; +import { FALCON512 } from "./schemes/falcon512.js"; +import { HAWK512 } from "./schemes/hawk512.js"; +import { Artifact } from "./vector.js"; + +const toHex = (b: Uint8Array): string => Buffer.from(b).toString("hex"); +const fromHex = (s: string): Uint8Array => new Uint8Array(Buffer.from(s, "hex")); + +const SYSTEM_PROGRAM = "11111111111111111111111111111111"; +const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + +/** Resolve a {@link Scheme} from its on-chain program id. */ +export function schemeForProgramId(programId: Address | string): Scheme { + const id = typeof programId === "string" ? programId : programId.toBase58(); + for (const s of [ED25519, SECP256K1, EIP191, FALCON512, HAWK512]) { + if (s.programId.toBase58() === id) return s; + } + throw new Error(`unknown scheme program id: ${id}`); +} + +// ── Serialization ──────────────────────────────────────────────────── + +interface SerializedKey { + pubkey: string; + isSigner: boolean; + isWritable: boolean; +} +interface SerializedIx { + programId: string; + keys: SerializedKey[]; + data: string; // hex +} + +function ixToSerialized(ix: TransactionInstruction): SerializedIx { + return { + programId: ix.programId.toBase58(), + keys: ix.keys.map((k) => ({ + pubkey: k.pubkey.toBase58(), + isSigner: k.isSigner, + isWritable: k.isWritable, + })), + data: toHex(ix.data), + }; +} + +function serializedToIx(s: SerializedIx): TransactionInstruction { + return new TransactionInstruction({ + programId: new Address(s.programId), + keys: s.keys.map((k) => ({ + pubkey: new Address(k.pubkey), + isSigner: k.isSigner, + isWritable: k.isWritable, + })), + data: Buffer.from(fromHex(s.data)), + }); +} + +/** Deterministic JSON for transport. Equal artifacts → equal bytes. */ +export function serializeArtifact(a: Artifact): string { + return JSON.stringify({ + programId: a.programId.toBase58(), + identity: toHex(a.identity), + nonce: toHex(a.nonce), + nextNonce: toHex(a.nextNonce), + feePayer: a.feePayer ? a.feePayer.toBase58() : undefined, + instructions: a.instructions.map(ixToSerialized), + }); +} + +/** Rebuild an {@link Artifact} from {@link serializeArtifact} output. */ +export function deserializeArtifact(json: string): Artifact { + const o = JSON.parse(json); + const instructions = (o.instructions as SerializedIx[]).map(serializedToIx); + return { + programId: new Address(o.programId), + identity: fromHex(o.identity), + nonce: fromHex(o.nonce), + nextNonce: fromHex(o.nextNonce), + feePayer: o.feePayer ? new Address(o.feePayer) : undefined, + instructions, + transaction: () => new Transaction().add(...instructions), + }; +} + +// ── Decoding ───────────────────────────────────────────────────────── + +/** A decoded sub-instruction from a passthrough payload. */ +export interface DecodedIx { + programId: Address; + accounts: { pubkey: Address; isWritable: boolean }[]; + data: Uint8Array; +} + +function decodePassthrough(passthrough: TransactionInstruction): DecodedIx[] { + const data = new Uint8Array(passthrough.data); + const numIxs = data[1]; + let dOff = 2; + let kOff = 2; // keys: [vector_pda, instructions_sysvar, then per ix program+accounts] + const out: DecodedIx[] = []; + for (let i = 0; i < numIxs; i++) { + const numAccounts = data[dOff]; + const dataLen = readU16LE(data, dOff + 1); + const ixData = data.slice(dOff + 3, dOff + 3 + dataLen); + dOff += 3 + dataLen; + const programId = passthrough.keys[kOff].pubkey; + const accounts = passthrough.keys + .slice(kOff + 1, kOff + 1 + numAccounts) + .map((k) => ({ pubkey: k.pubkey, isWritable: k.isWritable })); + kOff += 1 + numAccounts; + out.push({ programId, accounts, data: ixData }); + } + return out; +} + +/** Decode every CPI an artifact will execute under the PDA. */ +export function decodeOps(a: Artifact): DecodedIx[] { + const out: DecodedIx[] = []; + for (const ix of a.instructions) { + if ( + ix.programId.toBase58() === a.programId.toBase58() && + ix.data[0] === PASSTHROUGH_DISCRIMINATOR + ) { + out.push(...decodePassthrough(ix)); + } + } + return out; +} + +// ── Intent summary ─────────────────────────────────────────────────── + +function readU64LE(b: Uint8Array, off: number): bigint { + let v = 0n; + for (let i = 0; i < 8; i++) v |= BigInt(b[off + i]) << BigInt(8 * i); + return v; +} +function short(a: Address): string { + const s = a.toBase58(); + return `${s.slice(0, 4)}…${s.slice(-4)}`; +} + +/** Human-readable intent lines. Unknown programs render raw, never hidden. */ +export function summarize(a: Artifact): string[] { + const vector = a.programId.toBase58(); + return decodeOps(a).map((ix) => { + const pid = ix.programId.toBase58(); + if (pid === vector && ix.data[0] === WITHDRAW_DISCRIMINATOR) { + return `Vector withdraw ${readU64LE(ix.data, 1)} lamports → ${short(ix.accounts[1].pubkey)}`; + } + if (pid === vector && ix.data[0] === CLOSE_DISCRIMINATOR) { + return `Vector close → ${short(ix.accounts[1].pubkey)}`; + } + if (pid === SYSTEM_PROGRAM && ix.data.length >= 12 && ix.data[0] === 2) { + return `System transfer ${readU64LE(ix.data, 4)} lamports ${short(ix.accounts[0].pubkey)} → ${short(ix.accounts[1].pubkey)}`; + } + if (pid === TOKEN_PROGRAM && ix.data[0] === 3) { + return `SPL transfer ${readU64LE(ix.data, 1)} ${short(ix.accounts[0].pubkey)} → ${short(ix.accounts[1].pubkey)}`; + } + return `unknown program ${short(ix.programId)}: ${ix.data.length} bytes, ${ix.accounts.length} accounts`; + }); +} + +// ── Offline verification ───────────────────────────────────────────── + +/** + * Recompute the canonical digest from the artifact's instruction layout and + * verify the signature against its identity — no chain access. Ed25519 is + * implemented; other schemes throw with a clear extension point. + */ +export function verifyArtifact(a: Artifact): boolean { + const scheme = schemeForProgramId(a.programId); + const post = a.instructions.slice(1); // everything after the advance + const digest = advanceVectorDigest(scheme, a.nonce, a.identity, [], post, a.feePayer); + + if (scheme.programId.toBase58() !== ED25519.programId.toBase58()) { + throw new Error(`verifyArtifact: ${a.programId.toBase58()} not implemented yet`); + } + const advanceData = new Uint8Array(a.instructions[0].data); + if (advanceData[0] !== ADVANCE_DISCRIMINATOR) return false; + const signature = advanceData.slice(1, 1 + scheme.signatureLen); + try { + return ed25519.verify(signature, digest, a.identity); + } catch { + return false; + } +} + +// ── Review rendering ───────────────────────────────────────────────── + +/** A deterministic, human-readable review block for a policy engine / display. */ +export function review(a: Artifact): string { + const lines = ["VECTOR ARTIFACT"]; + lines.push(`scheme: ${a.programId.toBase58()}`); + lines.push(`identity: ${toHex(a.identity).slice(0, 8)}…`); + lines.push(`nonce: ${toHex(a.nonce).slice(0, 8)}…`); + if (a.feePayer) lines.push(`fee payer: ${a.feePayer.toBase58()}`); + lines.push("intent:"); + for (const line of summarize(a)) lines.push(` - ${line}`); + return lines.join("\n"); +} diff --git a/sdk/ts/src/vector.ts b/sdk/ts/src/vector.ts index d5cca71..cf8ecac 100644 --- a/sdk/ts/src/vector.ts +++ b/sdk/ts/src/vector.ts @@ -68,10 +68,16 @@ export type Op = TransactionInstruction | TransactionInstruction[]; /** A signed, broadcast-ready authorization with the advance/passthrough wired. */ export interface Artifact { + /** The scheme program this artifact authorizes against. */ + programId: Address; + /** The signing identity (Ed25519: the 32-byte public key). */ + identity: Uint8Array; /** Nonce this artifact is signed against; valid only while the PDA sits here. */ nonce: Uint8Array; /** Nonce the chain holds after this artifact executes. */ nextNonce: Uint8Array; + /** Fee payer the digest was bound to, if any (needed to re-verify offline). */ + feePayer?: Address; /** Instructions to broadcast in order: `[advance]` or `[advance, passthrough]`. */ instructions: TransactionInstruction[]; /** A fresh `Transaction` of {@link instructions} (relayer adds blockhash + fee-payer sig). */ @@ -202,8 +208,11 @@ export class Vector { private toArtifact(step: SignedStep): Artifact { const instructions = [step.advanceIx, ...step.post]; return { + programId: this.scheme.programId, + identity: this.identity, nonce: step.nonce, nextNonce: step.nextNonce, + feePayer: this.feePayer, instructions, transaction: () => new Transaction().add(...instructions), }; diff --git a/sdk/ts/test/inspect.unit.test.ts b/sdk/ts/test/inspect.unit.test.ts new file mode 100644 index 0000000..5c8ffae --- /dev/null +++ b/sdk/ts/test/inspect.unit.test.ts @@ -0,0 +1,69 @@ +import { describe, test, expect } from "vitest"; +import { Address, SystemProgram } from "@solana/web3.js"; +import { + Vector, + serializeArtifact, + deserializeArtifact, + decodeOps, + summarize, + verifyArtifact, + review, +} from "../src/index.js"; + +const KEY = new Uint8Array(32); +KEY[31] = 0x11; +const A = new Address("11111111111111111111111111111112"); +const B = new Address("11111111111111111111111111111113"); +const NONCE = new Uint8Array(32).fill(2); +const v = Vector.ed25519(KEY); // no feePayer → digest is feePayer-independent +const transfer = SystemProgram.transfer({ fromPubkey: A, toPubkey: B, lamports: 5 }); + +describe("serialize", () => { + test("round-trips stably", () => { + const art = v.authorize(NONCE, transfer); + const j1 = serializeArtifact(art); + const j2 = serializeArtifact(deserializeArtifact(j1)); + expect(j2).toBe(j1); + }); +}); + +describe("decodeOps + summarize", () => { + test("decodes the CPI and renders a System transfer", () => { + const art = v.authorize(NONCE, transfer); + const ops = decodeOps(art); + expect(ops.length).toBe(1); + expect(ops[0].programId.toBase58()).toBe(SystemProgram.programId.toBase58()); + expect(summarize(art)[0]).toContain("System transfer"); + expect(summarize(art)[0]).toContain("5"); + }); + + test("empty op (revocation) decodes to nothing", () => { + const art = v.authorize(NONCE, []); + expect(decodeOps(art).length).toBe(0); + }); +}); + +describe("verifyArtifact", () => { + test("accepts a correctly signed artifact", () => { + const art = v.authorize(NONCE, transfer); + expect(verifyArtifact(art)).toBe(true); + }); + + test("rejects a tampered op", () => { + const art = v.authorize(NONCE, transfer); + // mutate the passthrough payload → digest no longer matches the signature + const data = art.instructions[1].data; + data[data.length - 1] ^= 0xff; + expect(verifyArtifact(art)).toBe(false); + }); +}); + +describe("review", () => { + test("renders a deterministic block", () => { + const art = v.authorize(NONCE, transfer); + const text = review(art); + expect(text).toContain("VECTOR ARTIFACT"); + expect(text).toContain("System transfer"); + expect(review(art)).toBe(text); + }); +}); From 79695bf6694513070fbe8c6c007e8d1f9cfbfcf7 Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 15:19:12 +0200 Subject: [PATCH 08/14] feat(sdk): migration scanner + fund-in-PDA wallet/migration builders + docs --- package.json | 12 +++ sdk/ts/README.md | 36 +++++++ sdk/ts/src/index.ts | 3 + sdk/ts/src/migrate.ts | 76 ++++++++++++++ sdk/ts/src/scan.ts | 175 +++++++++++++++++++++++++++++++ sdk/ts/src/scheme.ts | 2 +- sdk/ts/src/wallet.ts | 71 +++++++++++++ sdk/ts/test/migrate.unit.test.ts | 58 ++++++++++ sdk/ts/test/scan.unit.test.ts | 101 ++++++++++++++++++ sdk/ts/test/wallet.test.ts | 58 ++++++++++ 10 files changed, 591 insertions(+), 1 deletion(-) create mode 100644 sdk/ts/src/migrate.ts create mode 100644 sdk/ts/src/scan.ts create mode 100644 sdk/ts/src/wallet.ts create mode 100644 sdk/ts/test/migrate.unit.test.ts create mode 100644 sdk/ts/test/scan.unit.test.ts create mode 100644 sdk/ts/test/wallet.test.ts diff --git a/package.json b/package.json index bebb629..4244cbd 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,18 @@ "import": "./sdk/ts/dist/inspect.js", "types": "./sdk/ts/dist/inspect.d.ts" }, + "./wallet": { + "import": "./sdk/ts/dist/wallet.js", + "types": "./sdk/ts/dist/wallet.d.ts" + }, + "./migrate": { + "import": "./sdk/ts/dist/migrate.js", + "types": "./sdk/ts/dist/migrate.d.ts" + }, + "./scan": { + "import": "./sdk/ts/dist/scan.js", + "types": "./sdk/ts/dist/scan.d.ts" + }, "./branching": { "import": "./sdk/ts/dist/branching.js", "types": "./sdk/ts/dist/branching.d.ts" diff --git a/sdk/ts/README.md b/sdk/ts/README.md index e2260b3..8e8e903 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -144,6 +144,42 @@ console.log(review(a)); // deterministic, human-readable block for sign-off Unknown programs are rendered raw (program id + byte/account counts), never silently hidden — so a reviewer always sees the full intent. +## Migration & the scanner (fund-in-PDA) + +The PDA *is* the wallet: hold SOL and tokens in it and spend with offline +artifacts. Move existing holdings in with the migration builders, then **audit +with the scanner** so nothing is left on the old key before the deprecation +cutoff. + +```ts +import { + createMigrateSolInstruction, createPdaAtaInstruction, + associatedTokenAddress, createSplTransferIx, createWithdrawSubinstruction, + scanMigration, +} from "vector-sdk"; + +// spend SOL out of the PDA (through the facade) +v.authorize(nonce, createWithdrawSubinstruction(v.programId, v.identity, to, 1_000n)); + +// spend tokens out of the PDA's ATA +const source = associatedTokenAddress(mint, v.pda); +v.authorize(nonce, createSplTransferIx(source, destAta, v.pda, 50n)); + +// audit: did everything leave the old key? +const report = await scanMigration(connection, { + owner: oldKey, // the keypair you're migrating away from + pda: v.pda, // the Vector account it should now point at + mints: [usdcMint], // declared — mint/freeze authorities aren't queryable by authority +}); +if (!report.complete) console.table(report.unmigrated); // your migration to-do list +``` + +The scanner **auto-discovers** what Solana can index by authority — native SOL, +SPL + Token-2022 accounts, and stake accounts. Mint/freeze authorities and +arbitrary program authorities are **not** indexed by authority, so you declare +them (`mints`, `accounts`) and the scanner verifies each one points at the PDA. +`report.complete` is true only when nothing controllable remains on the old key. + ## Air-gapped signing Signing is **synchronous and offline** — `authorize`/`chain`/`branch` take a diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index beee5e5..d834b9f 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -42,6 +42,9 @@ export * from "./digest.js"; export * from "./vector.js"; export * from "./inspect.js"; +export * from "./wallet.js"; +export * from "./migrate.js"; +export * from "./scan.js"; export * from "./schemes/ed25519.js"; export * from "./schemes/eip191.js"; diff --git a/sdk/ts/src/migrate.ts b/sdk/ts/src/migrate.ts new file mode 100644 index 0000000..689cc9f --- /dev/null +++ b/sdk/ts/src/migrate.ts @@ -0,0 +1,76 @@ +/** + * Migration into the fund-in-PDA model. The common path needs no authority + * reassignment at all: move SOL into the PDA and create PDA-owned token + * accounts. An optional in-place `SetAuthority`→PDA path is provided for + * callers who must keep an existing token account; for STAKE accounts note + * the runtime forbids changing the withdraw authority under an active lockup — + * those must wait for lockup expiry (out of scope here). + * + * Pair these with {@link scanMigration} to confirm nothing is left behind. + */ +import { Address, SystemProgram, TransactionInstruction } from "@solana/web3.js"; +import { + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + associatedTokenAddress, +} from "./wallet.js"; + +const SYSTEM_PROGRAM_ID = new Address("11111111111111111111111111111111"); + +/** Move SOL from an existing wallet into the PDA wallet. Old wallet signs. */ +export function createMigrateSolInstruction( + oldWallet: Address, + pda: Address, + lamports: number +): TransactionInstruction { + return SystemProgram.transfer({ fromPubkey: oldWallet, toPubkey: pda, lamports }); +} + +/** Create the PDA-owned ATA (Associated Token Account `Create`, data = []). */ +export function createPdaAtaInstruction( + payer: Address, + pda: Address, + mint: Address, + tokenProgram: Address = TOKEN_PROGRAM_ID +): TransactionInstruction { + const ata = associatedTokenAddress(mint, pda, tokenProgram); + return new TransactionInstruction({ + programId: ASSOCIATED_TOKEN_PROGRAM_ID, + keys: [ + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: ata, isSigner: false, isWritable: true }, + { pubkey: pda, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: SYSTEM_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: tokenProgram, isSigner: false, isWritable: false }, + ], + data: Buffer.from([]), + }); +} + +/** + * In-place SPL `SetAuthority` handing a token account's authority to the PDA. + * data = `[6 (SetAuthority), authorityType, hasNewAuthority(1), newAuthority(32)]`. + * authorityType 2 = AccountOwner; 0 = MintTokens; 1 = FreezeAccount. + */ +export function createTokenAuthorityReassignment( + account: Address, + currentAuthority: Address, + newAuthorityPda: Address, + authorityType = 2, + tokenProgram: Address = TOKEN_PROGRAM_ID +): TransactionInstruction { + const data = new Uint8Array(1 + 1 + 1 + 32); + data[0] = 6; + data[1] = authorityType; + data[2] = 1; // COption::Some + data.set(newAuthorityPda.toBytes(), 3); + return new TransactionInstruction({ + programId: tokenProgram, + keys: [ + { pubkey: account, isSigner: false, isWritable: true }, + { pubkey: currentAuthority, isSigner: true, isWritable: false }, + ], + data: Buffer.from(data), + }); +} diff --git a/sdk/ts/src/scan.ts b/sdk/ts/src/scan.ts new file mode 100644 index 0000000..c76b27b --- /dev/null +++ b/sdk/ts/src/scan.ts @@ -0,0 +1,175 @@ +/** + * Migration scanner — audit everything an old keypair authority still controls + * vs. the target Vector PDA, so an institution can prove (before the durable- + * nonce deprecation cutoff) that nothing was left behind. + * + * Auto-discovers what Solana can be queried by authority — native SOL, SPL + + * Token-2022 accounts, stake accounts. Mint/freeze authorities and arbitrary + * program authorities are NOT indexed by authority, so you declare those and + * the scanner verifies each one points at the PDA. + */ +import { Address, Connection } from "@solana/web3.js"; +import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "./wallet.js"; + +const STAKE_PROGRAM_ID = new Address( + "Stake11111111111111111111111111111111111111" +); + +export type AuthorityKind = + | "sol" + | "token" + | "token2022" + | "stake" + | "mint-authority" + | "freeze-authority" + | "account"; + +/** One audited item: an account the old authority did or did not migrate. */ +export interface ScanItem { + kind: AuthorityKind; + /** Base58 address of the controlled account (or the old wallet, for SOL). */ + address: string; + status: "migrated" | "unmigrated"; + detail?: string; +} + +export interface MigrationReport { + owner: string; + pda: string; + /** True iff nothing controllable remains on the old authority. */ + complete: boolean; + items: ScanItem[]; + /** Convenience: just the items still on the old key — your to-do list. */ + unmigrated: ScanItem[]; +} + +export interface ScanOptions { + /** The old keypair authority being migrated away from. */ + owner: Address; + /** The target Vector PDA authority should now point at. */ + pda: Address; + /** Mints to check (mint + freeze authority) — not discoverable by authority. */ + mints?: Address[]; + /** Generic accounts: verify the 32-byte authority at `authorityOffset`. */ + accounts?: { + address: Address; + authorityOffset: number; + kind?: AuthorityKind; + label?: string; + }[]; + /** SOL balance (lamports) at/below which `owner` counts as drained. Default 0. */ + dustLamports?: number; +} + +function pubkeyAt(data: Uint8Array, offset: number): string { + return new Address(data.slice(offset, offset + 32)).toBase58(); +} + +/** + * Audit the migration of `owner` → `pda`. `complete` is true iff nothing + * controllable remains on `owner`. Run it, migrate `report.unmigrated`, + * re-run until complete. + */ +export async function scanMigration( + connection: Connection, + opts: ScanOptions +): Promise { + const owner = opts.owner.toBase58(); + const pda = opts.pda.toBase58(); + const items: ScanItem[] = []; + + // 1. Native SOL. + const balance = await connection.getBalance(opts.owner); + if (balance > (opts.dustLamports ?? 0)) { + items.push({ + kind: "sol", + address: owner, + status: "unmigrated", + detail: `${balance} lamports still on the old key`, + }); + } else { + items.push({ kind: "sol", address: owner, status: "migrated" }); + } + + // 2. SPL + Token-2022 accounts owned by the old key. + for (const [programId, kind] of [ + [TOKEN_PROGRAM_ID, "token"], + [TOKEN_2022_PROGRAM_ID, "token2022"], + ] as const) { + const res = await connection.getTokenAccountsByOwner(opts.owner, { programId }); + for (const { pubkey } of res.value) { + items.push({ + kind, + address: pubkey.toBase58(), + status: "unmigrated", + detail: "token account still owned by the old key", + }); + } + } + + // 3. Stake accounts where the old key is staker or withdraw authority. + const seenStake = new Set(); + for (const [offset, role] of [ + [12, "stake authority"], + [44, "withdraw authority"], + ] as const) { + const accts = (await connection.getProgramAccounts(STAKE_PROGRAM_ID, { + filters: [{ memcmp: { offset, bytes: owner } }], + dataSlice: { offset: 0, length: 0 }, + } as any)) as unknown as { pubkey: Address }[]; + for (const { pubkey } of accts) { + const addr = pubkey.toBase58(); + if (seenStake.has(addr)) continue; + seenStake.add(addr); + items.push({ + kind: "stake", + address: addr, + status: "unmigrated", + detail: `old key is the ${role}`, + }); + } + } + + // 4. Declared mints — mint authority (COption @0/@4) + freeze (@46/@50). + for (const mint of opts.mints ?? []) { + const info = await connection.getAccountInfo(mint); + if (!info) continue; + const data = new Uint8Array(info.data); + if (data[0] === 1) { + const auth = pubkeyAt(data, 4); + items.push({ + kind: "mint-authority", + address: mint.toBase58(), + status: auth === pda ? "migrated" : "unmigrated", + detail: auth === pda ? undefined : `mint authority is ${auth}`, + }); + } + if (data.length >= 82 && data[46] === 1) { + const auth = pubkeyAt(data, 50); + items.push({ + kind: "freeze-authority", + address: mint.toBase58(), + status: auth === pda ? "migrated" : "unmigrated", + detail: auth === pda ? undefined : `freeze authority is ${auth}`, + }); + } + } + + // 5. Declared generic accounts — authority at a given offset. + for (const a of opts.accounts ?? []) { + const info = await connection.getAccountInfo(a.address); + if (!info) continue; + const auth = pubkeyAt(new Uint8Array(info.data), a.authorityOffset); + items.push({ + kind: a.kind ?? "account", + address: a.address.toBase58(), + status: auth === pda ? "migrated" : "unmigrated", + detail: + (a.label ? a.label + ": " : "") + + (auth === pda ? "authority is the PDA" : `authority is ${auth}`), + }); + } + + const unmigrated = items.filter((i) => i.status === "unmigrated"); + return { owner, pda, complete: unmigrated.length === 0, items, unmigrated }; +} diff --git a/sdk/ts/src/scheme.ts b/sdk/ts/src/scheme.ts index 1978e95..557a236 100644 --- a/sdk/ts/src/scheme.ts +++ b/sdk/ts/src/scheme.ts @@ -134,7 +134,7 @@ const PDA_MARKER = new TextEncoder().encode("ProgramDerivedAddress"); * `sha256(seeds || bump || program_id || "ProgramDerivedAddress")` that * is *off* the ed25519 curve. */ -function findProgramAddressSync( +export function findProgramAddressSync( seeds: Uint8Array[], programId: Address ): [Address, number] { diff --git a/sdk/ts/src/wallet.ts b/sdk/ts/src/wallet.ts new file mode 100644 index 0000000..eae097f --- /dev/null +++ b/sdk/ts/src/wallet.ts @@ -0,0 +1,71 @@ +/** + * Fund-in-PDA wallet helpers. The Vector PDA *is* the wallet: it holds native + * SOL directly and owns SPL token accounts, and spends are authorized by an + * offline Vector signature (see {@link Vector}). ATA derivation and the SPL + * instructions are built by hand so the SDK needs no `@solana/spl-token`. + * + * Spends themselves go through the facade — e.g. `v.authorize(nonce, + * createWithdrawSubinstruction(...))` for SOL, or `v.authorize(nonce, + * createSplTransferIx(...))` for tokens. + */ +import { Address, SystemProgram, TransactionInstruction } from "@solana/web3.js"; +import { findProgramAddressSync, writeU64LE } from "./scheme.js"; + +export const TOKEN_PROGRAM_ID = new Address( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" +); +export const TOKEN_2022_PROGRAM_ID = new Address( + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" +); +export const ASSOCIATED_TOKEN_PROGRAM_ID = new Address( + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" +); + +/** Derive the off-curve ATA for `mint` owned by `owner` (a PDA is fine). */ +export function associatedTokenAddress( + mint: Address, + owner: Address, + tokenProgram: Address = TOKEN_PROGRAM_ID +): Address { + const [ata] = findProgramAddressSync( + [owner.toBytes(), tokenProgram.toBytes(), mint.toBytes()], + ASSOCIATED_TOKEN_PROGRAM_ID + ); + return ata; +} + +/** Move SOL into the PDA wallet (a plain System transfer). */ +export function createFundWalletInstruction( + payer: Address, + pda: Address, + lamports: number +): TransactionInstruction { + return SystemProgram.transfer({ fromPubkey: payer, toPubkey: pda, lamports }); +} + +/** + * SPL Token `Transfer`, built by hand. `authority` is typically the Vector + * PDA; inside a passthrough its signer flag is cleared and the runtime + * promotes the PDA to signer at CPI time. Pass {@link TOKEN_2022_PROGRAM_ID} + * for Token-2022 mints. + */ +export function createSplTransferIx( + source: Address, + destination: Address, + authority: Address, + amount: bigint, + tokenProgram: Address = TOKEN_PROGRAM_ID +): TransactionInstruction { + const data = new Uint8Array(1 + 8); + data[0] = 3; // Transfer + writeU64LE(data, amount, 1); + return new TransactionInstruction({ + programId: tokenProgram, + keys: [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { pubkey: authority, isSigner: true, isWritable: false }, + ], + data: Buffer.from(data), + }); +} diff --git a/sdk/ts/test/migrate.unit.test.ts b/sdk/ts/test/migrate.unit.test.ts new file mode 100644 index 0000000..ca7f4a2 --- /dev/null +++ b/sdk/ts/test/migrate.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect } from "vitest"; +import { Address } from "@solana/web3.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import "./helpers.js"; // applies the Address shim spl-token@0.4 expects +import { + associatedTokenAddress, + createSplTransferIx, + createFundWalletInstruction, + TOKEN_PROGRAM_ID, + createMigrateSolInstruction, + createPdaAtaInstruction, + createTokenAuthorityReassignment, +} from "../src/index.js"; + +const PDA = new Address("11111111111111111111111111111113"); +const MINT = new Address("So11111111111111111111111111111111111111112"); +const PAY = new Address("11111111111111111111111111111112"); + +describe("wallet", () => { + test("associatedTokenAddress matches spl-token's off-curve derivation", () => { + const mine = associatedTokenAddress(MINT, PDA); + const expected = getAssociatedTokenAddressSync(MINT as any, PDA as any, true); + expect(mine.toBase58()).toBe(expected.toBase58()); + }); + + test("createSplTransferIx encodes Transfer (disc 3, 3 accounts)", () => { + const ix = createSplTransferIx(PAY, PAY, PDA, 42n); + expect(ix.programId.toBase58()).toBe(TOKEN_PROGRAM_ID.toBase58()); + expect(ix.data[0]).toBe(3); + expect(ix.keys.length).toBe(3); + }); + + test("createFundWalletInstruction transfers into the pda", () => { + const ix = createFundWalletInstruction(PAY, PDA, 1000); + expect(ix.keys[1].pubkey.toBase58()).toBe(PDA.toBase58()); + }); +}); + +describe("migrate", () => { + test("migrate SOL transfers old → pda", () => { + const ix = createMigrateSolInstruction(PAY, PDA, 5); + expect(ix.keys[0].pubkey.toBase58()).toBe(PAY.toBase58()); + expect(ix.keys[1].pubkey.toBase58()).toBe(PDA.toBase58()); + }); + + test("create PDA ATA targets the off-curve ATA owned by the pda", () => { + const ix = createPdaAtaInstruction(PAY, PDA, MINT); + expect(ix.keys[1].pubkey.toBase58()).toBe( + associatedTokenAddress(MINT, PDA).toBase58() + ); + expect(ix.keys[2].pubkey.toBase58()).toBe(PDA.toBase58()); + }); + + test("token authority reassignment is SetAuthority → pda", () => { + const ix = createTokenAuthorityReassignment(PAY, PAY, PDA); + expect(ix.data[0]).toBe(6); + }); +}); diff --git a/sdk/ts/test/scan.unit.test.ts b/sdk/ts/test/scan.unit.test.ts new file mode 100644 index 0000000..3e2f1c4 --- /dev/null +++ b/sdk/ts/test/scan.unit.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect } from "vitest"; +import { Address, Connection } from "@solana/web3.js"; +import { scanMigration, TOKEN_PROGRAM_ID } from "../src/index.js"; + +const owner = new Address("11111111111111111111111111111112"); +const pda = new Address("11111111111111111111111111111113"); +const TA1 = new Address("11111111111111111111111111111114"); +const TA2 = new Address("11111111111111111111111111111115"); +const STK = new Address("11111111111111111111111111111116"); +const MINT = new Address("11111111111111111111111111111117"); + +function mintData(authority: Address | null, freeze: Address | null): Uint8Array { + const d = new Uint8Array(82); + if (authority) { + d[0] = 1; + d.set(authority.toBytes(), 4); + } + d[45] = 1; // is_initialized + if (freeze) { + d[46] = 1; + d.set(freeze.toBytes(), 50); + } + return d; +} + +function mockConn(opts: { + balance: number; + tokenAccounts?: Record; + stake?: Record; + accountInfos?: Record; +}): Connection { + return { + getBalance: async (_addr: Address) => opts.balance, + getTokenAccountsByOwner: async ( + _owner: Address, + { programId }: { programId: Address } + ) => ({ + value: (opts.tokenAccounts?.[programId.toBase58()] ?? []).map((pubkey) => ({ + pubkey, + })), + }), + getProgramAccounts: async (_pid: Address, cfg: any) => { + const offset = cfg.filters[0].memcmp.offset; + return (opts.stake?.[offset] ?? []).map((pubkey) => ({ pubkey })); + }, + getAccountInfo: async (addr: Address) => { + const data = opts.accountInfos?.[addr.toBase58()]; + return data ? { data } : null; + }, + } as unknown as Connection; +} + +describe("scanMigration", () => { + test("flags everything still on the old key", async () => { + const conn = mockConn({ + balance: 5_000_000, + tokenAccounts: { [TOKEN_PROGRAM_ID.toBase58()]: [TA1, TA2] }, + stake: { 44: [STK] }, // withdraw-authority offset + accountInfos: { [MINT.toBase58()]: mintData(owner, null) }, + }); + + const report = await scanMigration(conn, { owner, pda, mints: [MINT] }); + + expect(report.complete).toBe(false); + expect(report.unmigrated.length).toBe(5); // sol + 2 token + stake + mint + expect(report.unmigrated.some((i) => i.kind === "sol")).toBe(true); + expect(report.unmigrated.filter((i) => i.kind === "token").length).toBe(2); + expect(report.unmigrated.some((i) => i.kind === "stake")).toBe(true); + expect(report.unmigrated.some((i) => i.kind === "mint-authority")).toBe(true); + }); + + test("complete when drained and authority points at the pda", async () => { + const conn = mockConn({ + balance: 0, + accountInfos: { [MINT.toBase58()]: mintData(pda, null) }, + }); + const report = await scanMigration(conn, { owner, pda, mints: [MINT] }); + + expect(report.complete).toBe(true); + expect(report.unmigrated.length).toBe(0); + expect(report.items.find((i) => i.kind === "sol")?.status).toBe("migrated"); + expect(report.items.find((i) => i.kind === "mint-authority")?.status).toBe( + "migrated" + ); + }); + + test("declared generic account: migrated iff authority is the pda", async () => { + const ACC = new Address("11111111111111111111111111111118"); + const data = new Uint8Array(40); + data.set(pda.toBytes(), 8); + const conn = mockConn({ balance: 0, accountInfos: { [ACC.toBase58()]: data } }); + + const report = await scanMigration(conn, { + owner, + pda, + accounts: [{ address: ACC, authorityOffset: 8, label: "config" }], + }); + expect(report.complete).toBe(true); + expect(report.items.find((i) => i.kind === "account")?.status).toBe("migrated"); + }); +}); diff --git a/sdk/ts/test/wallet.test.ts b/sdk/ts/test/wallet.test.ts new file mode 100644 index 0000000..d975cfc --- /dev/null +++ b/sdk/ts/test/wallet.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { Connection, Keypair, Transaction } from "@solana/web3.js"; +import { + Vector, + ED25519, + createWithdrawSubinstruction, + createFundWalletInstruction, + scanMigration, +} from "../src/index.js"; +import { RPC_URL, WS_URL, FEE_PAYER_SEED, sendTx } from "./helpers.js"; + +const KEY = new Uint8Array(32); +KEY[31] = 0x51; + +describe("fund-in-PDA wallet (on-chain)", () => { + let connection: Connection; + let feePayer: Keypair; + + beforeAll(async () => { + connection = new Connection(RPC_URL, { commitment: "confirmed", wsEndpoint: WS_URL }); + feePayer = await Keypair.fromSeed(FEE_PAYER_SEED); + }); + + afterAll(() => { + (connection as any)._rpcWebSocket?.close(); + }); + + test("PDA holds SOL and spends it via an offline-signed artifact", async () => { + const v = Vector.ed25519(KEY, { feePayer: feePayer.address }); + await sendTx( + connection, + new Transaction() + .add(v.initialize(feePayer.address)) + .add(createFundWalletInstruction(feePayer.address, v.pda, 5_000_000)), + [feePayer] + ); + + const nonce = await v.nonce(connection); + const before = (await connection.getAccountInfo(v.pda))!.lamports; + + const withdraw = createWithdrawSubinstruction(ED25519, v.identity, feePayer.address, 1_000n); + const art = v.authorize(nonce, withdraw); + await sendTx(connection, art.transaction(), [feePayer]); + + const after = (await connection.getAccountInfo(v.pda))!.lamports; + expect(Number(before) - Number(after)).toBe(1_000); + expect(Buffer.from(await v.nonce(connection))).toEqual(Buffer.from(art.nextNonce)); + }); + + test("scanMigration runs against real RPC and flags the old key's SOL", async () => { + const v = Vector.ed25519(KEY); + // Scanning the funded fee payer exercises getBalance / getTokenAccountsByOwner + // / getProgramAccounts against the live validator (shape validation). + const report = await scanMigration(connection, { owner: feePayer.address, pda: v.pda }); + expect(report.complete).toBe(false); + expect(report.items.find((i) => i.kind === "sol")?.status).toBe("unmigrated"); + }); +}); From 948f70e64d2dcad42dcfff05a5f4b4d207fc93f3 Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 15:43:07 +0200 Subject: [PATCH 09/14] =?UTF-8?q?fix(sdk):=20review=20pass=20=E2=80=94=20a?= =?UTF-8?q?dd=20v.withdraw/close,=20verify=20guard=20order=20+=20summarize?= =?UTF-8?q?=20bounds,=20doc=20limits,=20feePayer/unsupported-scheme=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/ts/README.md | 6 +++--- sdk/ts/src/inspect.ts | 32 ++++++++++++++++++---------- sdk/ts/src/vector.ts | 36 ++++++++++++++++++++++++++++---- sdk/ts/test/inspect.unit.test.ts | 29 ++++++++++++++++++++++++- sdk/ts/test/vector.unit.test.ts | 18 ++++++++++++++++ 5 files changed, 102 insertions(+), 19 deletions(-) diff --git a/sdk/ts/README.md b/sdk/ts/README.md index 8e8e903..e348d64 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -154,12 +154,12 @@ cutoff. ```ts import { createMigrateSolInstruction, createPdaAtaInstruction, - associatedTokenAddress, createSplTransferIx, createWithdrawSubinstruction, + associatedTokenAddress, createSplTransferIx, scanMigration, } from "vector-sdk"; -// spend SOL out of the PDA (through the facade) -v.authorize(nonce, createWithdrawSubinstruction(v.programId, v.identity, to, 1_000n)); +// spend SOL out of the PDA — facade convenience for the program's own withdraw +v.withdraw(nonce, to, 1_000n); // spend tokens out of the PDA's ATA const source = associatedTokenAddress(mint, v.pda); diff --git a/sdk/ts/src/inspect.ts b/sdk/ts/src/inspect.ts index b823f35..f944437 100644 --- a/sdk/ts/src/inspect.ts +++ b/sdk/ts/src/inspect.ts @@ -163,19 +163,20 @@ export function summarize(a: Artifact): string[] { const vector = a.programId.toBase58(); return decodeOps(a).map((ix) => { const pid = ix.programId.toBase58(); - if (pid === vector && ix.data[0] === WITHDRAW_DISCRIMINATOR) { + const raw = `unknown program ${short(ix.programId)}: ${ix.data.length} bytes, ${ix.accounts.length} accounts`; + if (pid === vector && ix.data[0] === WITHDRAW_DISCRIMINATOR && ix.data.length >= 9 && ix.accounts.length >= 2) { return `Vector withdraw ${readU64LE(ix.data, 1)} lamports → ${short(ix.accounts[1].pubkey)}`; } - if (pid === vector && ix.data[0] === CLOSE_DISCRIMINATOR) { + if (pid === vector && ix.data[0] === CLOSE_DISCRIMINATOR && ix.accounts.length >= 2) { return `Vector close → ${short(ix.accounts[1].pubkey)}`; } - if (pid === SYSTEM_PROGRAM && ix.data.length >= 12 && ix.data[0] === 2) { + if (pid === SYSTEM_PROGRAM && ix.data[0] === 2 && ix.data.length >= 12 && ix.accounts.length >= 2) { return `System transfer ${readU64LE(ix.data, 4)} lamports ${short(ix.accounts[0].pubkey)} → ${short(ix.accounts[1].pubkey)}`; } - if (pid === TOKEN_PROGRAM && ix.data[0] === 3) { + if (pid === TOKEN_PROGRAM && ix.data[0] === 3 && ix.data.length >= 9 && ix.accounts.length >= 2) { return `SPL transfer ${readU64LE(ix.data, 1)} ${short(ix.accounts[0].pubkey)} → ${short(ix.accounts[1].pubkey)}`; } - return `unknown program ${short(ix.programId)}: ${ix.data.length} bytes, ${ix.accounts.length} accounts`; + return raw; }); } @@ -183,19 +184,28 @@ export function summarize(a: Artifact): string[] { /** * Recompute the canonical digest from the artifact's instruction layout and - * verify the signature against its identity — no chain access. Ed25519 is - * implemented; other schemes throw with a clear extension point. + * verify the signature against its identity — no chain access. Returns + * `false` for a bad signature. **Throws** for schemes whose offline verify is + * not implemented yet (only Ed25519 is today) — catch it if you inspect mixed + * schemes. Assumes facade-shaped artifacts (`[advance, ...passthrough]`). */ export function verifyArtifact(a: Artifact): boolean { const scheme = schemeForProgramId(a.programId); - const post = a.instructions.slice(1); // everything after the advance - const digest = advanceVectorDigest(scheme, a.nonce, a.identity, [], post, a.feePayer); - if (scheme.programId.toBase58() !== ED25519.programId.toBase58()) { - throw new Error(`verifyArtifact: ${a.programId.toBase58()} not implemented yet`); + throw new Error( + `verifyArtifact: offline verification for ${a.programId.toBase58()} is not implemented yet` + ); } const advanceData = new Uint8Array(a.instructions[0].data); if (advanceData[0] !== ADVANCE_DISCRIMINATOR) return false; + const digest = advanceVectorDigest( + scheme, + a.nonce, + a.identity, + [], + a.instructions.slice(1), + a.feePayer + ); const signature = advanceData.slice(1, 1 + scheme.signatureLen); try { return ed25519.verify(signature, digest, a.identity); diff --git a/sdk/ts/src/vector.ts b/sdk/ts/src/vector.ts index cf8ecac..77c126e 100644 --- a/sdk/ts/src/vector.ts +++ b/sdk/ts/src/vector.ts @@ -44,7 +44,11 @@ import { ed25519Identity, createInitializeEd25519, } from "./schemes/ed25519.js"; -import { createPassthroughInstruction } from "./instructions.js"; +import { + createPassthroughInstruction, + createWithdrawSubinstruction, + createCloseSubinstruction, +} from "./instructions.js"; import { ChainSigner, ed25519ChainSigner, @@ -137,6 +141,26 @@ export class Vector { return this.chain(nonce, [op])[0]; } + /** + * Authorize a SOL withdrawal from this account's PDA to `to`. Convenience + * for the program's own `withdraw` instruction — no need to reach for the + * low-level builder or supply the scheme/identity. + */ + withdraw(nonce: Uint8Array, to: Address, lamports: bigint): Artifact { + return this.authorize( + nonce, + createWithdrawSubinstruction(this.scheme, this.identity, to, lamports) + ); + } + + /** Authorize closing this account, sending its remaining lamports to `to`. */ + close(nonce: Uint8Array, to: Address): Artifact { + return this.authorize( + nonce, + createCloseSubinstruction(this.scheme, this.identity, to) + ); + } + /** * Authorize an ordered, forward-secure chain of ops starting at `nonce`. * Op `i` is signed against the nonce op `i-1` produces, so the ops can only @@ -155,7 +179,8 @@ export class Vector { /** * Authorize mutually-exclusive alternatives from one `nonce`. All share the * same parent state, so executing any one orphans the rest atomically. - * Returns an artifact per label. + * Returns an artifact per label. Each label is a single op; for multi-step + * alternative chains drop to the low-level {@link signBranches}. */ branch(nonce: Uint8Array, named: Record): Record { const labels = Object.keys(named); @@ -175,7 +200,8 @@ export class Vector { /** * Derive an independent sub-account (its own chain) from this account's key. * Use for non-exclusive parallel work (e.g. one position per RFQ deal). The - * returned `Vector` has the same API and a distinct identity/PDA. + * returned `Vector` has the same API and a distinct identity/PDA, and + * inherits this account's `feePayer`. */ derive(index: number): Vector { const childSeed = deriveLaneSeed(this.key, "ed25519", index); @@ -184,7 +210,9 @@ export class Vector { /** * Where a pre-signed chain stands given the current on-chain nonce: - * `pending` (and which op is next), `completed`, or `orphaned`. + * `pending` (and which op is next), `completed`, or `orphaned`. Assumes + * facade-shaped artifacts (`[advance, ...passthrough]`), which is everything + * the facade emits. */ status(artifacts: Artifact[], currentNonce: Uint8Array): ChainStatus { const steps: SignedStep[] = artifacts.map((a, index) => ({ diff --git a/sdk/ts/test/inspect.unit.test.ts b/sdk/ts/test/inspect.unit.test.ts index 5c8ffae..752c9d2 100644 --- a/sdk/ts/test/inspect.unit.test.ts +++ b/sdk/ts/test/inspect.unit.test.ts @@ -1,7 +1,8 @@ import { describe, test, expect } from "vitest"; -import { Address, SystemProgram } from "@solana/web3.js"; +import { Address, SystemProgram, Transaction } from "@solana/web3.js"; import { Vector, + SECP256K1, serializeArtifact, deserializeArtifact, decodeOps, @@ -58,6 +59,32 @@ describe("verifyArtifact", () => { }); }); +describe("verifyArtifact with a fee payer", () => { + test("verifies an artifact whose digest binds the fee payer", () => { + const feePayer = new Address("11111111111111111111111111111119"); + const vf = Vector.ed25519(KEY, { feePayer }); + // op references the fee payer → exercises message-flag promotion in the digest + const op = SystemProgram.transfer({ fromPubkey: feePayer, toPubkey: B, lamports: 9 }); + const art = vf.authorize(NONCE, op); + expect(art.feePayer?.toBase58()).toBe(feePayer.toBase58()); + expect(verifyArtifact(art)).toBe(true); + }); +}); + +describe("verifyArtifact unsupported scheme", () => { + test("throws for a non-ed25519 artifact", () => { + const fake = { + programId: SECP256K1.programId, + identity: new Uint8Array(33), + nonce: new Uint8Array(32), + nextNonce: new Uint8Array(32), + instructions: [], + transaction: () => new Transaction(), + } as any; + expect(() => verifyArtifact(fake)).toThrow(); + }); +}); + describe("review", () => { test("renders a deterministic block", () => { const art = v.authorize(NONCE, transfer); diff --git a/sdk/ts/test/vector.unit.test.ts b/sdk/ts/test/vector.unit.test.ts index f6239ab..27cfbba 100644 --- a/sdk/ts/test/vector.unit.test.ts +++ b/sdk/ts/test/vector.unit.test.ts @@ -88,6 +88,24 @@ describe("derive", () => { }); }); +describe("withdraw / close", () => { + const v = Vector.ed25519(KEY, { feePayer: PAY }); + const to = new Address("11111111111111111111111111111119"); + + test("withdraw builds a passthrough'd withdraw artifact", () => { + const art = v.withdraw(NONCE, to, 1000n); + expect(art.instructions.length).toBe(2); + expect(art.instructions[0].data[0]).toBe(ADVANCE_DISCRIMINATOR); + expect(art.instructions[1].data[0]).toBe(PASSTHROUGH_DISCRIMINATOR); + }); + + test("close builds a passthrough'd close artifact", () => { + const art = v.close(NONCE, to); + expect(art.instructions.length).toBe(2); + expect(art.instructions[1].data[0]).toBe(PASSTHROUGH_DISCRIMINATOR); + }); +}); + describe("status", () => { const v = Vector.ed25519(KEY, { feePayer: PAY }); const arts = v.chain(NONCE, [ix(1), ix(2)]); From bf91695099c386cd8c7218ea69377668445295a2 Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 15:56:10 +0200 Subject: [PATCH 10/14] =?UTF-8?q?feat(sdk):=20multi-scheme=20facade=20?= =?UTF-8?q?=E2=80=94=20Vector.{secp256k1,eip191,falcon512}=20+=20on-chain?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/ts/README.md | 16 ++++- sdk/ts/src/branching.ts | 46 +++++++++++++ sdk/ts/src/vector.ts | 114 ++++++++++++++++++++++++++------ sdk/ts/test/vector.test.ts | 20 +++++- sdk/ts/test/vector.unit.test.ts | 47 +++++++++++++ 5 files changed, 217 insertions(+), 26 deletions(-) diff --git a/sdk/ts/README.md b/sdk/ts/README.md index e348d64..95b070a 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -205,6 +205,16 @@ non-Ed25519 schemes, bespoke digest handling). ## Schemes -`Vector.ed25519` is implemented today. The protocol also ships secp256k1, -EIP-191 (Ethereum wallets), and post-quantum Falcon-512 / Hawk-512 programs with -the identical instruction set; their facade constructors follow the same shape. +The facade covers four schemes — same API, different `Vector.*` constructor: + +| Constructor | Key material | Identity | +| :-- | :-- | :-- | +| `Vector.ed25519(seed)` | 32-byte Ed25519 seed | 32-byte public key | +| `Vector.secp256k1(privKey)` | 32-byte secp256k1 key | 33-byte compressed pubkey | +| `Vector.eip191(privKey)` | 32-byte secp256k1 key | 20-byte Ethereum address — sign with any `personal_sign` wallet | +| `Vector.falcon512(keypair)` | Falcon-512 keypair | `sha256(wire pubkey)` (post-quantum) | + +`derive(i)` works for the 32-byte-key schemes (ed25519 / secp256k1 / eip191); +Falcon sub-accounts are constructed from their own keypairs. **Hawk-512** isn't +on the facade — its registration is a 3-transaction flow (`initialize` → +`storeWire` → `finalize`); use the low-level scheme builders for it. diff --git a/sdk/ts/src/branching.ts b/sdk/ts/src/branching.ts index 2ec36de..ee44f8d 100644 --- a/sdk/ts/src/branching.ts +++ b/sdk/ts/src/branching.ts @@ -19,6 +19,22 @@ import { ed25519Identity, signAdvanceInstructionEd25519, } from "./schemes/ed25519.js"; +import { + SECP256K1, + secp256k1Identity, + signAdvanceInstructionSecp256k1, +} from "./schemes/secp256k1.js"; +import { + EIP191, + eip191Identity, + signAdvanceInstructionEip191, +} from "./schemes/eip191.js"; +import { + FALCON512, + falcon512Identity, + signAdvanceInstructionFalcon512, + Falcon512Keypair, +} from "./schemes/falcon512.js"; import { advanceVectorDigest } from "./digest.js"; /** Per-scheme signer for a single chain (one key / identity). */ @@ -44,6 +60,36 @@ export function ed25519ChainSigner(signingKey: Uint8Array): ChainSigner { }; } +/** Plain secp256k1 ECDSA chain signer (32-byte private key). */ +export function secp256k1ChainSigner(privateKey: Uint8Array): ChainSigner { + return { + scheme: SECP256K1, + identity: secp256k1Identity(privateKey), + sign: (nonce, pre, post, feePayer) => + signAdvanceInstructionSecp256k1(privateKey, nonce, pre, post, feePayer), + }; +} + +/** EIP-191 (Ethereum) chain signer (32-byte secp256k1 private key). */ +export function eip191ChainSigner(privateKey: Uint8Array): ChainSigner { + return { + scheme: EIP191, + identity: eip191Identity(privateKey), + sign: (nonce, pre, post, feePayer) => + signAdvanceInstructionEip191(privateKey, nonce, pre, post, feePayer), + }; +} + +/** Falcon-512 (post-quantum) chain signer (1281-byte secret + 897-byte wire pubkey). */ +export function falcon512ChainSigner(keypair: Falcon512Keypair): ChainSigner { + return { + scheme: FALCON512, + identity: falcon512Identity(keypair.publicKey), + sign: (nonce, pre, post, feePayer) => + signAdvanceInstructionFalcon512(keypair, nonce, pre, post, feePayer), + }; +} + /** One step in a chain: instructions placed around the advance. */ export interface BranchStep { /** Top-level ixs before the advance (committed to by the digest). */ diff --git a/sdk/ts/src/vector.ts b/sdk/ts/src/vector.ts index 77c126e..6284101 100644 --- a/sdk/ts/src/vector.ts +++ b/sdk/ts/src/vector.ts @@ -39,11 +39,14 @@ import { } from "@solana/web3.js"; import { Scheme, findVectorPda, fetchVectorAccount } from "./scheme.js"; +import { ED25519, createInitializeEd25519 } from "./schemes/ed25519.js"; +import { SECP256K1, createInitializeSecp256k1 } from "./schemes/secp256k1.js"; +import { EIP191, createInitializeEip191 } from "./schemes/eip191.js"; import { - ED25519, - ed25519Identity, - createInitializeEd25519, -} from "./schemes/ed25519.js"; + FALCON512, + createInitializeFalcon512, + Falcon512Keypair, +} from "./schemes/falcon512.js"; import { createPassthroughInstruction, createWithdrawSubinstruction, @@ -52,6 +55,9 @@ import { import { ChainSigner, ed25519ChainSigner, + secp256k1ChainSigner, + eip191ChainSigner, + falcon512ChainSigner, signChain, signBranches, resolveChainStatus, @@ -96,35 +102,95 @@ export class Vector { /** The account's PDA — its on-chain address. */ readonly pda: Address; - private readonly key: Uint8Array; private readonly signer: ChainSigner; private readonly feePayer?: Address; + private readonly initIx: (payer: Address) => TransactionInstruction; + private readonly deriveChild?: (index: number) => Vector; - private constructor( - scheme: Scheme, - key: Uint8Array, - signer: ChainSigner, - pda: Address, - feePayer?: Address - ) { - this.scheme = scheme; - this.key = key; - this.signer = signer; - this.identity = signer.identity; - this.pda = pda; - this.feePayer = feePayer; + private constructor(args: { + scheme: Scheme; + signer: ChainSigner; + pda: Address; + initIx: (payer: Address) => TransactionInstruction; + feePayer?: Address; + deriveChild?: (index: number) => Vector; + }) { + this.scheme = args.scheme; + this.signer = args.signer; + this.identity = args.signer.identity; + this.pda = args.pda; + this.initIx = args.initIx; + this.feePayer = args.feePayer; + this.deriveChild = args.deriveChild; } /** Bind a `Vector` to a 32-byte Ed25519 private-key seed. */ static ed25519(key: Uint8Array, opts?: { feePayer?: Address }): Vector { const signer = ed25519ChainSigner(key); const [pda] = findVectorPda(ED25519, signer.identity); - return new Vector(ED25519, key, signer, pda, opts?.feePayer); + return new Vector({ + scheme: ED25519, + signer, + pda, + initIx: (payer) => createInitializeEd25519(payer, signer.identity), + feePayer: opts?.feePayer, + deriveChild: (i) => Vector.ed25519(deriveLaneSeed(key, "ed25519", i), opts), + }); + } + + /** Bind a `Vector` to a 32-byte plain secp256k1 (ECDSA) private key. */ + static secp256k1(privateKey: Uint8Array, opts?: { feePayer?: Address }): Vector { + const signer = secp256k1ChainSigner(privateKey); + const [pda] = findVectorPda(SECP256K1, signer.identity); + return new Vector({ + scheme: SECP256K1, + signer, + pda, + initIx: (payer) => createInitializeSecp256k1(payer, signer.identity), + feePayer: opts?.feePayer, + deriveChild: (i) => + Vector.secp256k1(deriveLaneSeed(privateKey, "secp256k1", i), opts), + }); + } + + /** Bind a `Vector` to a 32-byte secp256k1 key, signing as an Ethereum (EIP-191) address. */ + static eip191(privateKey: Uint8Array, opts?: { feePayer?: Address }): Vector { + const signer = eip191ChainSigner(privateKey); + const [pda] = findVectorPda(EIP191, signer.identity); + return new Vector({ + scheme: EIP191, + signer, + pda, + initIx: (payer) => createInitializeEip191(payer, signer.identity), + feePayer: opts?.feePayer, + deriveChild: (i) => + Vector.eip191(deriveLaneSeed(privateKey, "eip191", i), opts), + }); + } + + /** + * Bind a `Vector` to a post-quantum Falcon-512 keypair. `derive` is + * unavailable (Falcon has no 32-byte seed) — construct each sub-account from + * its own keypair. + */ + static falcon512( + keypair: Falcon512Keypair, + opts?: { feePayer?: Address } + ): Vector { + const signer = falcon512ChainSigner(keypair); + const [pda] = findVectorPda(FALCON512, signer.identity); + return new Vector({ + scheme: FALCON512, + signer, + pda, + initIx: (payer) => createInitializeFalcon512(payer, keypair.publicKey), + feePayer: opts?.feePayer, + }); } /** The one-time `initialize` instruction that creates this account on-chain. */ initialize(payer: Address): TransactionInstruction { - return createInitializeEd25519(payer, this.identity); + return this.initIx(payer); } /** Read the current on-chain nonce. The only networked call. */ @@ -204,8 +270,12 @@ export class Vector { * inherits this account's `feePayer`. */ derive(index: number): Vector { - const childSeed = deriveLaneSeed(this.key, "ed25519", index); - return Vector.ed25519(childSeed, { feePayer: this.feePayer }); + if (!this.deriveChild) { + throw new Error( + `derive() is unavailable for ${this.scheme.programId.toBase58()} (no seed-based sub-key derivation); construct each sub-account from its own keypair` + ); + } + return this.deriveChild(index); } /** diff --git a/sdk/ts/test/vector.test.ts b/sdk/ts/test/vector.test.ts index 9599521..aa0768e 100644 --- a/sdk/ts/test/vector.test.ts +++ b/sdk/ts/test/vector.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeAll, afterAll } from "vitest"; -import { Connection, Keypair, SystemProgram, Transaction } from "@solana/web3.js"; +import { Address, Connection, Keypair, SystemProgram, Transaction } from "@solana/web3.js"; import { Vector } from "../src/index.js"; import { RPC_URL, WS_URL, FEE_PAYER_SEED, sendTx } from "./helpers.js"; @@ -89,4 +89,22 @@ describe("Vector (front door, on-chain)", () => { expect(Buffer.from(await a.nonce(connection))).not.toEqual(Buffer.from(na)); // a moved expect(Buffer.from(await b.nonce(connection))).toEqual(Buffer.from(nb)); // b untouched }); + + // Non-ed25519 schemes verify on-chain through the same facade. + const SCHEMES = [ + { name: "secp256k1", last: 0x61, make: (k: Uint8Array, o: { feePayer: Address }) => Vector.secp256k1(k, o) }, + { name: "eip191", last: 0x62, make: (k: Uint8Array, o: { feePayer: Address }) => Vector.eip191(k, o) }, + ]; + for (const s of SCHEMES) { + test(`${s.name}: facade init + authorize advances on-chain`, async () => { + const key = new Uint8Array(32); + key[31] = s.last; + const v = s.make(key, { feePayer: feePayer.address }); + await sendTx(connection, new Transaction().add(v.initialize(feePayer.address)), [feePayer]); + const nonce = await v.nonce(connection); + const art = v.authorize(nonce, []); // inert advance + await sendTx(connection, art.transaction(), [feePayer]); + expect(Buffer.from(await v.nonce(connection))).toEqual(Buffer.from(art.nextNonce)); + }); + } }); diff --git a/sdk/ts/test/vector.unit.test.ts b/sdk/ts/test/vector.unit.test.ts index 27cfbba..a7965be 100644 --- a/sdk/ts/test/vector.unit.test.ts +++ b/sdk/ts/test/vector.unit.test.ts @@ -3,7 +3,14 @@ import { Address, SystemProgram, Transaction } from "@solana/web3.js"; import { Vector, ED25519, + SECP256K1, + EIP191, + FALCON512, ed25519Identity, + secp256k1Identity, + eip191Identity, + falcon512Identity, + falcon512Keygen, findVectorPda, ADVANCE_DISCRIMINATOR, PASSTHROUGH_DISCRIMINATOR, @@ -106,6 +113,46 @@ describe("withdraw / close", () => { }); }); +describe("multi-scheme facade", () => { + const secpKey = new Uint8Array(32); + secpKey[31] = 7; // valid secp256k1 scalar + + test("secp256k1 binds compressed-pubkey identity + signs", () => { + const v = Vector.secp256k1(secpKey, { feePayer: PAY }); + expect(v.scheme.programId.toBase58()).toBe(SECP256K1.programId.toBase58()); + expect(Buffer.from(v.identity)).toEqual(Buffer.from(secp256k1Identity(secpKey))); + expect(v.pda.toBase58()).toBe(findVectorPda(SECP256K1, v.identity)[0].toBase58()); + const art = v.authorize(NONCE, ix(1)); + expect(art.instructions[0].data[0]).toBe(ADVANCE_DISCRIMINATOR); + expect(art.instructions[0].data.length).toBe(1 + SECP256K1.signatureLen); // 65 + }); + + test("eip191 binds the 20-byte ETH address identity + signs (65-byte sig)", () => { + const v = Vector.eip191(secpKey, { feePayer: PAY }); + expect(v.scheme.programId.toBase58()).toBe(EIP191.programId.toBase58()); + expect(v.identity.length).toBe(20); + expect(Buffer.from(v.identity)).toEqual(Buffer.from(eip191Identity(secpKey))); + const art = v.authorize(NONCE, ix(1)); + expect(art.instructions[0].data.length).toBe(1 + EIP191.signatureLen); // 1 + 65 + }); + + test("falcon512 binds sha256(wire) identity; derive throws", () => { + const kp = falcon512Keygen(); + const v = Vector.falcon512(kp, { feePayer: PAY }); + expect(v.scheme.programId.toBase58()).toBe(FALCON512.programId.toBase58()); + expect(Buffer.from(v.identity)).toEqual(Buffer.from(falcon512Identity(kp.publicKey))); + const art = v.authorize(NONCE, ix(1)); + expect(art.instructions[0].data.length).toBe(1 + FALCON512.signatureLen); // 1 + 666 + expect(() => v.derive(0)).toThrow(); + }); + + test("secp256k1 derive yields deterministic, distinct sub-accounts", () => { + const v = Vector.secp256k1(secpKey); + expect(v.derive(0).pda.toBase58()).toBe(v.derive(0).pda.toBase58()); + expect(v.derive(0).pda.toBase58()).not.toBe(v.derive(1).pda.toBase58()); + }); +}); + describe("status", () => { const v = Vector.ed25519(KEY, { feePayer: PAY }); const arts = v.chain(NONCE, [ix(1), ix(2)]); From a54b1f14f9f4d3acd28a1d9afdf4b19822393517 Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 16:14:51 +0200 Subject: [PATCH 11/14] feat(sdk): offline verifyArtifact for secp256k1/eip191/falcon512 (all facade schemes) --- sdk/ts/README.md | 7 ++- sdk/ts/src/inspect.ts | 83 +++++++++++++++++++++++++++++--- sdk/ts/src/vector.ts | 11 +++++ sdk/ts/test/inspect.unit.test.ts | 36 ++++++++++++-- 4 files changed, 123 insertions(+), 14 deletions(-) diff --git a/sdk/ts/README.md b/sdk/ts/README.md index 95b070a..81ea992 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -141,8 +141,11 @@ verifyArtifact(a); // true | false — recompute digest + check signature, off console.log(review(a)); // deterministic, human-readable block for sign-off ``` -Unknown programs are rendered raw (program id + byte/account counts), never -silently hidden — so a reviewer always sees the full intent. +`verifyArtifact` covers all four facade schemes (Ed25519, secp256k1, EIP-191, +Falcon-512 — Falcon artifacts carry their wire pubkey); Hawk-512 is +on-chain-verify-only. Unknown programs are rendered raw (program id + +byte/account counts), never silently hidden — so a reviewer always sees the +full intent. ## Migration & the scanner (fund-in-PDA) diff --git a/sdk/ts/src/inspect.ts b/sdk/ts/src/inspect.ts index f944437..9ca63d6 100644 --- a/sdk/ts/src/inspect.ts +++ b/sdk/ts/src/inspect.ts @@ -6,6 +6,9 @@ */ import { Address, Transaction, TransactionInstruction } from "@solana/web3.js"; import { ed25519 } from "@noble/curves/ed25519"; +import { secp256k1 } from "@noble/curves/secp256k1"; +import { keccak_256 } from "@noble/hashes/sha3"; +import { falcon512 as nobleFalcon } from "@noble/post-quantum/falcon.js"; import { Scheme, @@ -83,6 +86,7 @@ export function serializeArtifact(a: Artifact): string { nonce: toHex(a.nonce), nextNonce: toHex(a.nextNonce), feePayer: a.feePayer ? a.feePayer.toBase58() : undefined, + publicKey: a.publicKey ? toHex(a.publicKey) : undefined, instructions: a.instructions.map(ixToSerialized), }); } @@ -97,6 +101,7 @@ export function deserializeArtifact(json: string): Artifact { nonce: fromHex(o.nonce), nextNonce: fromHex(o.nextNonce), feePayer: o.feePayer ? new Address(o.feePayer) : undefined, + publicKey: o.publicKey ? fromHex(o.publicKey) : undefined, instructions, transaction: () => new Transaction().add(...instructions), }; @@ -182,22 +187,54 @@ export function summarize(a: Artifact): string[] { // ── Offline verification ───────────────────────────────────────────── +const EIP191_PREFIX = new TextEncoder().encode("\x19Ethereum Signed Message:\n32"); + +function eip191Hash(digest: Uint8Array): Uint8Array { + const buf = new Uint8Array(EIP191_PREFIX.length + digest.length); + buf.set(EIP191_PREFIX); + buf.set(digest, EIP191_PREFIX.length); + return keccak_256(buf); +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let d = 0; + for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; + return d === 0; +} + /** * Recompute the canonical digest from the artifact's instruction layout and - * verify the signature against its identity — no chain access. Returns - * `false` for a bad signature. **Throws** for schemes whose offline verify is - * not implemented yet (only Ed25519 is today) — catch it if you inspect mixed - * schemes. Assumes facade-shaped artifacts (`[advance, ...passthrough]`). + * verify the signature offline — no chain access. Supports the four facade + * schemes (Ed25519, secp256k1, EIP-191, Falcon-512). Returns `false` for a + * bad signature; **throws** for a scheme it can't verify offline (Hawk-512) + * or a Falcon artifact missing its `publicKey`. Assumes facade-shaped + * artifacts (`[advance, ...passthrough]`). */ export function verifyArtifact(a: Artifact): boolean { const scheme = schemeForProgramId(a.programId); - if (scheme.programId.toBase58() !== ED25519.programId.toBase58()) { + const pid = scheme.programId.toBase58(); + + const supported = [ + ED25519.programId.toBase58(), + SECP256K1.programId.toBase58(), + EIP191.programId.toBase58(), + FALCON512.programId.toBase58(), + ]; + if (!supported.includes(pid)) { + throw new Error( + `verifyArtifact: offline verification for ${pid} is not implemented (e.g. Hawk-512 — use on-chain verification)` + ); + } + if (pid === FALCON512.programId.toBase58() && !a.publicKey) { throw new Error( - `verifyArtifact: offline verification for ${a.programId.toBase58()} is not implemented yet` + "verifyArtifact: Falcon artifact is missing publicKey (the wire pubkey)" ); } + const advanceData = new Uint8Array(a.instructions[0].data); if (advanceData[0] !== ADVANCE_DISCRIMINATOR) return false; + const signature = advanceData.slice(1, 1 + scheme.signatureLen); const digest = advanceVectorDigest( scheme, a.nonce, @@ -206,9 +243,39 @@ export function verifyArtifact(a: Artifact): boolean { a.instructions.slice(1), a.feePayer ); - const signature = advanceData.slice(1, 1 + scheme.signatureLen); + try { - return ed25519.verify(signature, digest, a.identity); + if (pid === ED25519.programId.toBase58()) { + return ed25519.verify(signature, digest, a.identity); + } + if (pid === SECP256K1.programId.toBase58()) { + return secp256k1.verify(signature, digest, a.identity); + } + if (pid === EIP191.programId.toBase58()) { + const recovered = secp256k1.Signature.fromCompact(signature.slice(0, 64)) + .addRecoveryBit(signature[64]) + .recoverPublicKey(eip191Hash(digest)) + .toRawBytes(false); // 65-byte uncompressed: 0x04 || x || y + return bytesEqual(keccak_256(recovered.slice(1)).slice(12, 32), a.identity); + } + // Falcon-512: identity is sha256(wire), so verify against the wire pubkey. + // The on-chain wire zero-pads the compressed signature to a fixed size, + // but noble needs its exact detached length. Recover it by scanning from + // the last non-zero byte up to the padded size — exactly one length + // verifies (shorter truncates, longer carries trailing padding noble + // rejects). + let end = signature.length; + while (end > 0 && signature[end - 1] === 0) end--; + for (let len = end; len <= signature.length; len++) { + try { + if (nobleFalcon.verify(signature.slice(0, len), digest, a.publicKey!)) { + return true; + } + } catch { + // wrong length — keep scanning + } + } + return false; } catch { return false; } diff --git a/sdk/ts/src/vector.ts b/sdk/ts/src/vector.ts index 6284101..85b3b8e 100644 --- a/sdk/ts/src/vector.ts +++ b/sdk/ts/src/vector.ts @@ -88,6 +88,12 @@ export interface Artifact { nextNonce: Uint8Array; /** Fee payer the digest was bound to, if any (needed to re-verify offline). */ feePayer?: Address; + /** + * Verification public key when it differs from {@link identity} — i.e. the + * Falcon/Hawk wire pubkey (`identity` is its `sha256`). Needed to verify the + * artifact offline; omitted for schemes where `identity` is the key itself. + */ + publicKey?: Uint8Array; /** Instructions to broadcast in order: `[advance]` or `[advance, passthrough]`. */ instructions: TransactionInstruction[]; /** A fresh `Transaction` of {@link instructions} (relayer adds blockhash + fee-payer sig). */ @@ -106,6 +112,7 @@ export class Vector { private readonly feePayer?: Address; private readonly initIx: (payer: Address) => TransactionInstruction; private readonly deriveChild?: (index: number) => Vector; + private readonly publicKey?: Uint8Array; private constructor(args: { scheme: Scheme; @@ -114,6 +121,7 @@ export class Vector { initIx: (payer: Address) => TransactionInstruction; feePayer?: Address; deriveChild?: (index: number) => Vector; + publicKey?: Uint8Array; }) { this.scheme = args.scheme; this.signer = args.signer; @@ -122,6 +130,7 @@ export class Vector { this.initIx = args.initIx; this.feePayer = args.feePayer; this.deriveChild = args.deriveChild; + this.publicKey = args.publicKey; } /** Bind a `Vector` to a 32-byte Ed25519 private-key seed. */ @@ -185,6 +194,7 @@ export class Vector { pda, initIx: (payer) => createInitializeFalcon512(payer, keypair.publicKey), feePayer: opts?.feePayer, + publicKey: keypair.publicKey, }); } @@ -311,6 +321,7 @@ export class Vector { nonce: step.nonce, nextNonce: step.nextNonce, feePayer: this.feePayer, + publicKey: this.publicKey, instructions, transaction: () => new Transaction().add(...instructions), }; diff --git a/sdk/ts/test/inspect.unit.test.ts b/sdk/ts/test/inspect.unit.test.ts index 752c9d2..047bc7a 100644 --- a/sdk/ts/test/inspect.unit.test.ts +++ b/sdk/ts/test/inspect.unit.test.ts @@ -2,7 +2,8 @@ import { describe, test, expect } from "vitest"; import { Address, SystemProgram, Transaction } from "@solana/web3.js"; import { Vector, - SECP256K1, + HAWK512, + falcon512Keygen, serializeArtifact, deserializeArtifact, decodeOps, @@ -71,11 +72,38 @@ describe("verifyArtifact with a fee payer", () => { }); }); +describe("verifyArtifact across schemes", () => { + const secpKey = new Uint8Array(32); + secpKey[31] = 7; + const op = SystemProgram.transfer({ fromPubkey: A, toPubkey: B, lamports: 5 }); + + test("ed25519", () => { + expect(verifyArtifact(Vector.ed25519(KEY).authorize(NONCE, op))).toBe(true); + }); + test("secp256k1", () => { + expect(verifyArtifact(Vector.secp256k1(secpKey).authorize(NONCE, op))).toBe(true); + }); + test("eip191", () => { + expect(verifyArtifact(Vector.eip191(secpKey).authorize(NONCE, op))).toBe(true); + }); + test("falcon512 carries the wire pubkey and verifies", () => { + const art = Vector.falcon512(falcon512Keygen()).authorize(NONCE, op); + expect(art.publicKey).toBeDefined(); + expect(verifyArtifact(art)).toBe(true); + }); + test("tamper is rejected (secp256k1)", () => { + const art = Vector.secp256k1(secpKey).authorize(NONCE, op); + const data = art.instructions[1].data; + data[data.length - 1] ^= 0xff; + expect(verifyArtifact(art)).toBe(false); + }); +}); + describe("verifyArtifact unsupported scheme", () => { - test("throws for a non-ed25519 artifact", () => { + test("throws for a scheme it can't verify offline (Hawk-512)", () => { const fake = { - programId: SECP256K1.programId, - identity: new Uint8Array(33), + programId: HAWK512.programId, + identity: new Uint8Array(32), nonce: new Uint8Array(32), nextNonce: new Uint8Array(32), instructions: [], From e22b86f72df0ecd943925cf1369dbdda3a007b5a Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 16:48:31 +0200 Subject: [PATCH 12/14] feat(sdk): facade Hawk-512 (register() multi-tx flow + compute-budget advance); offline verify now covers all 5 schemes --- sdk/ts/README.md | 24 ++++--- sdk/ts/src/branching.ts | 16 +++++ sdk/ts/src/inspect.ts | 31 +++++---- sdk/ts/src/vector.ts | 104 ++++++++++++++++++++++++++++--- sdk/ts/test/inspect.unit.test.ts | 15 +++-- sdk/ts/test/vector.test.ts | 14 ++++- sdk/ts/test/vector.unit.test.ts | 19 ++++++ 7 files changed, 186 insertions(+), 37 deletions(-) diff --git a/sdk/ts/README.md b/sdk/ts/README.md index 81ea992..199f25f 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -141,11 +141,10 @@ verifyArtifact(a); // true | false — recompute digest + check signature, off console.log(review(a)); // deterministic, human-readable block for sign-off ``` -`verifyArtifact` covers all four facade schemes (Ed25519, secp256k1, EIP-191, -Falcon-512 — Falcon artifacts carry their wire pubkey); Hawk-512 is -on-chain-verify-only. Unknown programs are rendered raw (program id + -byte/account counts), never silently hidden — so a reviewer always sees the -full intent. +`verifyArtifact` covers all five facade schemes (Ed25519, secp256k1, EIP-191, +Falcon-512, Hawk-512 — the post-quantum artifacts carry their wire pubkey). +Unknown programs are rendered raw (program id + byte/account counts), never +silently hidden — so a reviewer always sees the full intent. ## Migration & the scanner (fund-in-PDA) @@ -208,7 +207,7 @@ non-Ed25519 schemes, bespoke digest handling). ## Schemes -The facade covers four schemes — same API, different `Vector.*` constructor: +The facade covers five schemes — same API, different `Vector.*` constructor: | Constructor | Key material | Identity | | :-- | :-- | :-- | @@ -216,8 +215,15 @@ The facade covers four schemes — same API, different `Vector.*` constructor: | `Vector.secp256k1(privKey)` | 32-byte secp256k1 key | 33-byte compressed pubkey | | `Vector.eip191(privKey)` | 32-byte secp256k1 key | 20-byte Ethereum address — sign with any `personal_sign` wallet | | `Vector.falcon512(keypair)` | Falcon-512 keypair | `sha256(wire pubkey)` (post-quantum) | +| `Vector.hawk512(keypair)` | Hawk-512 keypair | `sha256(wire pubkey)` (post-quantum) | `derive(i)` works for the 32-byte-key schemes (ed25519 / secp256k1 / eip191); -Falcon sub-accounts are constructed from their own keypairs. **Hawk-512** isn't -on the facade — its registration is a 3-transaction flow (`initialize` → -`storeWire` → `finalize`); use the low-level scheme builders for it. +post-quantum sub-accounts are built from their own keypairs. + +**Registration.** `register(payer)` returns the account-creation transactions +in order — one instruction-group per transaction — and is the uniform path +across schemes. Single-transaction schemes return one group (and offer +`initialize(payer)` as a shorthand); **Hawk-512** returns three (`initialize` → +`storeWire` → `finalize`, its prepared pubkey is ~18 KB), and its advance +carries a compute-budget bump committed to by the digest — both handled by the +facade. diff --git a/sdk/ts/src/branching.ts b/sdk/ts/src/branching.ts index ee44f8d..7080ab3 100644 --- a/sdk/ts/src/branching.ts +++ b/sdk/ts/src/branching.ts @@ -35,6 +35,12 @@ import { signAdvanceInstructionFalcon512, Falcon512Keypair, } from "./schemes/falcon512.js"; +import { + HAWK512, + hawk512Identity, + signAdvanceInstructionHawk512, + Hawk512Keypair, +} from "./schemes/hawk512.js"; import { advanceVectorDigest } from "./digest.js"; /** Per-scheme signer for a single chain (one key / identity). */ @@ -90,6 +96,16 @@ export function falcon512ChainSigner(keypair: Falcon512Keypair): ChainSigner { }; } +/** Hawk-512 (post-quantum) chain signer (184-byte secret + 1024-byte wire pubkey). */ +export function hawk512ChainSigner(keypair: Hawk512Keypair): ChainSigner { + return { + scheme: HAWK512, + identity: hawk512Identity(keypair.publicKey), + sign: (nonce, pre, post, feePayer) => + signAdvanceInstructionHawk512(keypair, nonce, pre, post, feePayer), + }; +} + /** One step in a chain: instructions placed around the advance. */ export interface BranchStep { /** Top-level ixs before the advance (committed to by the digest). */ diff --git a/sdk/ts/src/inspect.ts b/sdk/ts/src/inspect.ts index 9ca63d6..79467e4 100644 --- a/sdk/ts/src/inspect.ts +++ b/sdk/ts/src/inspect.ts @@ -9,6 +9,7 @@ import { ed25519 } from "@noble/curves/ed25519"; import { secp256k1 } from "@noble/curves/secp256k1"; import { keccak_256 } from "@noble/hashes/sha3"; import { falcon512 as nobleFalcon } from "@noble/post-quantum/falcon.js"; +import { hawk512 as nobleHawk } from "@blueshift-gg/hawk512"; import { Scheme, @@ -87,6 +88,7 @@ export function serializeArtifact(a: Artifact): string { nextNonce: toHex(a.nextNonce), feePayer: a.feePayer ? a.feePayer.toBase58() : undefined, publicKey: a.publicKey ? toHex(a.publicKey) : undefined, + advanceIndex: a.advanceIndex, instructions: a.instructions.map(ixToSerialized), }); } @@ -102,6 +104,7 @@ export function deserializeArtifact(json: string): Artifact { nextNonce: fromHex(o.nextNonce), feePayer: o.feePayer ? new Address(o.feePayer) : undefined, publicKey: o.publicKey ? fromHex(o.publicKey) : undefined, + advanceIndex: o.advanceIndex ?? 0, instructions, transaction: () => new Transaction().add(...instructions), }; @@ -215,32 +218,24 @@ export function verifyArtifact(a: Artifact): boolean { const scheme = schemeForProgramId(a.programId); const pid = scheme.programId.toBase58(); - const supported = [ - ED25519.programId.toBase58(), - SECP256K1.programId.toBase58(), - EIP191.programId.toBase58(), - FALCON512.programId.toBase58(), - ]; - if (!supported.includes(pid)) { + const needsPubkey = + pid === FALCON512.programId.toBase58() || pid === HAWK512.programId.toBase58(); + if (needsPubkey && !a.publicKey) { throw new Error( - `verifyArtifact: offline verification for ${pid} is not implemented (e.g. Hawk-512 — use on-chain verification)` - ); - } - if (pid === FALCON512.programId.toBase58() && !a.publicKey) { - throw new Error( - "verifyArtifact: Falcon artifact is missing publicKey (the wire pubkey)" + "verifyArtifact: post-quantum artifact is missing publicKey (the wire pubkey)" ); } - const advanceData = new Uint8Array(a.instructions[0].data); + const idx = a.advanceIndex ?? 0; + const advanceData = new Uint8Array(a.instructions[idx].data); if (advanceData[0] !== ADVANCE_DISCRIMINATOR) return false; const signature = advanceData.slice(1, 1 + scheme.signatureLen); const digest = advanceVectorDigest( scheme, a.nonce, a.identity, - [], - a.instructions.slice(1), + a.instructions.slice(0, idx), + a.instructions.slice(idx + 1), a.feePayer ); @@ -251,6 +246,10 @@ export function verifyArtifact(a: Artifact): boolean { if (pid === SECP256K1.programId.toBase58()) { return secp256k1.verify(signature, digest, a.identity); } + if (pid === HAWK512.programId.toBase58()) { + // Hawk signature is fixed-size — no length recovery needed (unlike Falcon). + return nobleHawk.verify(signature, digest, a.publicKey!); + } if (pid === EIP191.programId.toBase58()) { const recovered = secp256k1.Signature.fromCompact(signature.slice(0, 64)) .addRecoveryBit(signature[64]) diff --git a/sdk/ts/src/vector.ts b/sdk/ts/src/vector.ts index 85b3b8e..048f81a 100644 --- a/sdk/ts/src/vector.ts +++ b/sdk/ts/src/vector.ts @@ -47,6 +47,13 @@ import { createInitializeFalcon512, Falcon512Keypair, } from "./schemes/falcon512.js"; +import { + HAWK512, + createInitializeHawk512, + createHawk512StoreWire, + createHawk512Finalize, + Hawk512Keypair, +} from "./schemes/hawk512.js"; import { createPassthroughInstruction, createWithdrawSubinstruction, @@ -58,6 +65,7 @@ import { secp256k1ChainSigner, eip191ChainSigner, falcon512ChainSigner, + hawk512ChainSigner, signChain, signBranches, resolveChainStatus, @@ -67,6 +75,25 @@ import { ChainStatus, } from "./branching.js"; +const COMPUTE_BUDGET_PROGRAM_ID = new Address( + "ComputeBudget111111111111111111111111111111" +); + +/** `ComputeBudgetProgram.setComputeUnitLimit` ix, built by hand (disc 2 + u32 LE). */ +function setComputeUnitLimitIx(units: number): TransactionInstruction { + const data = new Uint8Array(5); + data[0] = 2; + data[1] = units & 0xff; + data[2] = (units >>> 8) & 0xff; + data[3] = (units >>> 16) & 0xff; + data[4] = (units >>> 24) & 0xff; + return new TransactionInstruction({ + programId: COMPUTE_BUDGET_PROGRAM_ID, + keys: [], + data: Buffer.from(data), + }); +} + export type { ChainStatus } from "./branching.js"; /** @@ -94,7 +121,13 @@ export interface Artifact { * artifact offline; omitted for schemes where `identity` is the key itself. */ publicKey?: Uint8Array; - /** Instructions to broadcast in order: `[advance]` or `[advance, passthrough]`. */ + /** + * Index of the `advance` within {@link instructions}. 0 for most schemes; + * non-zero when the scheme prepends instructions (e.g. Hawk-512's + * compute-budget bump), which are committed to by the digest. + */ + advanceIndex: number; + /** Instructions to broadcast in order: `[...pre, advance, ...passthrough]`. */ instructions: TransactionInstruction[]; /** A fresh `Transaction` of {@link instructions} (relayer adds blockhash + fee-payer sig). */ transaction(): Transaction; @@ -110,27 +143,33 @@ export class Vector { private readonly signer: ChainSigner; private readonly feePayer?: Address; - private readonly initIx: (payer: Address) => TransactionInstruction; + private readonly initIx?: (payer: Address) => TransactionInstruction; + private readonly registerGroups?: (payer: Address) => TransactionInstruction[][]; private readonly deriveChild?: (index: number) => Vector; private readonly publicKey?: Uint8Array; + private readonly preIxs: TransactionInstruction[]; private constructor(args: { scheme: Scheme; signer: ChainSigner; pda: Address; - initIx: (payer: Address) => TransactionInstruction; + initIx?: (payer: Address) => TransactionInstruction; + registerGroups?: (payer: Address) => TransactionInstruction[][]; feePayer?: Address; deriveChild?: (index: number) => Vector; publicKey?: Uint8Array; + preIxs?: TransactionInstruction[]; }) { this.scheme = args.scheme; this.signer = args.signer; this.identity = args.signer.identity; this.pda = args.pda; this.initIx = args.initIx; + this.registerGroups = args.registerGroups; this.feePayer = args.feePayer; this.deriveChild = args.deriveChild; this.publicKey = args.publicKey; + this.preIxs = args.preIxs ?? []; } /** Bind a `Vector` to a 32-byte Ed25519 private-key seed. */ @@ -198,11 +237,57 @@ export class Vector { }); } - /** The one-time `initialize` instruction that creates this account on-chain. */ + /** + * Bind a `Vector` to a post-quantum Hawk-512 keypair. Registration is a + * three-transaction flow (see {@link register}), so `initialize` throws — + * use `register`. `derive` is unavailable (build each sub-account from its + * own keypair). The advance carries a compute-budget bump (Hawk verification + * is heavy), committed to by the digest. + */ + static hawk512(keypair: Hawk512Keypair, opts?: { feePayer?: Address }): Vector { + const signer = hawk512ChainSigner(keypair); + const [pda] = findVectorPda(HAWK512, signer.identity); + return new Vector({ + scheme: HAWK512, + signer, + pda, + registerGroups: (payer) => [ + [createInitializeHawk512(payer, keypair.publicKey)], + [createHawk512StoreWire(keypair.publicKey)], + [setComputeUnitLimitIx(600_000), createHawk512Finalize(keypair.publicKey)], + ], + feePayer: opts?.feePayer, + publicKey: keypair.publicKey, + preIxs: [setComputeUnitLimitIx(450_000)], + }); + } + + /** + * The one-time `initialize` instruction for single-transaction schemes. + * Throws for schemes whose registration spans multiple transactions + * (Hawk-512) — use {@link register} instead. + */ initialize(payer: Address): TransactionInstruction { + if (!this.initIx) { + throw new Error( + `${this.scheme.programId.toBase58()} needs multi-transaction registration — use register()` + ); + } return this.initIx(payer); } + /** + * All account-registration transactions, in order — one inner array per + * transaction. Single-transaction schemes return one group of one + * instruction; Hawk-512 returns three (initialize → store wire → finalize). + * Send each group as its own transaction, in order. + */ + register(payer: Address): TransactionInstruction[][] { + if (this.registerGroups) return this.registerGroups(payer); + if (this.initIx) return [[this.initIx(payer)]]; + throw new Error(`${this.scheme.programId.toBase58()} has no registration path`); + } + /** Read the current on-chain nonce. The only networked call. */ async nonce(connection: Connection): Promise { const account = await fetchVectorAccount(connection, this.scheme, this.identity); @@ -309,12 +394,16 @@ export class Vector { /** Wrap an op's CPIs in a passthrough (or nothing, for an inert advance). */ private toStep(op: Op): BranchStep { const ixs = Array.isArray(op) ? op : [op]; - if (ixs.length === 0) return {}; - return { post: [createPassthroughInstruction(this.scheme, this.identity, ixs)] }; + const pre = this.preIxs.length ? this.preIxs : undefined; + if (ixs.length === 0) return { pre }; + return { + pre, + post: [createPassthroughInstruction(this.scheme, this.identity, ixs)], + }; } private toArtifact(step: SignedStep): Artifact { - const instructions = [step.advanceIx, ...step.post]; + const instructions = [...step.pre, step.advanceIx, ...step.post]; return { programId: this.scheme.programId, identity: this.identity, @@ -322,6 +411,7 @@ export class Vector { nextNonce: step.nextNonce, feePayer: this.feePayer, publicKey: this.publicKey, + advanceIndex: step.pre.length, instructions, transaction: () => new Transaction().add(...instructions), }; diff --git a/sdk/ts/test/inspect.unit.test.ts b/sdk/ts/test/inspect.unit.test.ts index 047bc7a..4db39bd 100644 --- a/sdk/ts/test/inspect.unit.test.ts +++ b/sdk/ts/test/inspect.unit.test.ts @@ -2,8 +2,8 @@ import { describe, test, expect } from "vitest"; import { Address, SystemProgram, Transaction } from "@solana/web3.js"; import { Vector, - HAWK512, falcon512Keygen, + hawk512Keygen, serializeArtifact, deserializeArtifact, decodeOps, @@ -91,6 +91,12 @@ describe("verifyArtifact across schemes", () => { expect(art.publicKey).toBeDefined(); expect(verifyArtifact(art)).toBe(true); }); + test("hawk512 carries the wire pubkey and verifies (advance after compute budget)", () => { + const art = Vector.hawk512(hawk512Keygen()).authorize(NONCE, op); + expect(art.publicKey).toBeDefined(); + expect(art.advanceIndex).toBe(1); // compute-budget pre-instruction + expect(verifyArtifact(art)).toBe(true); + }); test("tamper is rejected (secp256k1)", () => { const art = Vector.secp256k1(secpKey).authorize(NONCE, op); const data = art.instructions[1].data; @@ -99,13 +105,14 @@ describe("verifyArtifact across schemes", () => { }); }); -describe("verifyArtifact unsupported scheme", () => { - test("throws for a scheme it can't verify offline (Hawk-512)", () => { +describe("verifyArtifact unknown scheme", () => { + test("throws for an unknown program id", () => { const fake = { - programId: HAWK512.programId, + programId: new Address("11111111111111111111111111111111"), // not a Vector scheme identity: new Uint8Array(32), nonce: new Uint8Array(32), nextNonce: new Uint8Array(32), + advanceIndex: 0, instructions: [], transaction: () => new Transaction(), } as any; diff --git a/sdk/ts/test/vector.test.ts b/sdk/ts/test/vector.test.ts index aa0768e..b892ede 100644 --- a/sdk/ts/test/vector.test.ts +++ b/sdk/ts/test/vector.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeAll, afterAll } from "vitest"; import { Address, Connection, Keypair, SystemProgram, Transaction } from "@solana/web3.js"; -import { Vector } from "../src/index.js"; +import { Vector, hawk512Keygen } from "../src/index.js"; import { RPC_URL, WS_URL, FEE_PAYER_SEED, sendTx } from "./helpers.js"; // Distinct keys so this suite never collides with the per-scheme tests. @@ -107,4 +107,16 @@ describe("Vector (front door, on-chain)", () => { expect(Buffer.from(await v.nonce(connection))).toEqual(Buffer.from(art.nextNonce)); }); } + + test("hawk512: 3-tx registration + advance on-chain", async () => { + const v = Vector.hawk512(hawk512Keygen(), { feePayer: feePayer.address }); + // register() returns one group per transaction (initialize / storeWire / finalize) + for (const group of v.register(feePayer.address)) { + await sendTx(connection, new Transaction().add(...group), [feePayer]); + } + const nonce = await v.nonce(connection); + const art = v.authorize(nonce, []); // inert advance; carries the compute-budget pre + await sendTx(connection, art.transaction(), [feePayer]); + expect(Buffer.from(await v.nonce(connection))).toEqual(Buffer.from(art.nextNonce)); + }, 60_000); }); diff --git a/sdk/ts/test/vector.unit.test.ts b/sdk/ts/test/vector.unit.test.ts index a7965be..41b1243 100644 --- a/sdk/ts/test/vector.unit.test.ts +++ b/sdk/ts/test/vector.unit.test.ts @@ -6,11 +6,14 @@ import { SECP256K1, EIP191, FALCON512, + HAWK512, ed25519Identity, secp256k1Identity, eip191Identity, falcon512Identity, falcon512Keygen, + hawk512Identity, + hawk512Keygen, findVectorPda, ADVANCE_DISCRIMINATOR, PASSTHROUGH_DISCRIMINATOR, @@ -151,6 +154,22 @@ describe("multi-scheme facade", () => { expect(v.derive(0).pda.toBase58()).toBe(v.derive(0).pda.toBase58()); expect(v.derive(0).pda.toBase58()).not.toBe(v.derive(1).pda.toBase58()); }); + + test("hawk512: 3-tx register(), compute-budget pre, no single initialize/derive", () => { + const kp = hawk512Keygen(); + const v = Vector.hawk512(kp, { feePayer: PAY }); + expect(v.scheme.programId.toBase58()).toBe(HAWK512.programId.toBase58()); + expect(Buffer.from(v.identity)).toEqual(Buffer.from(hawk512Identity(kp.publicKey))); + + const groups = v.register(PAY); + expect(groups.length).toBe(3); // initialize / storeWire / finalize + expect(() => v.initialize(PAY)).toThrow(); // multi-tx → use register() + + const art = v.authorize(NONCE, ix(1)); + expect(art.advanceIndex).toBe(1); // [computeBudget, advance, passthrough] + expect(art.instructions[art.advanceIndex].data[0]).toBe(ADVANCE_DISCRIMINATOR); + expect(() => v.derive(0)).toThrow(); + }); }); describe("status", () => { From 92e5d63505b189bba5d7ba92cec9dc3119b49f7e Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 22:08:21 +0200 Subject: [PATCH 13/14] refactor(sdk): per-scheme subpath entrypoints (tree-shakeable, core pulls no per-scheme crypto); native HKDF; offline verify via registry --- sdk/ts/README.md | 197 +++++++++---------------- sdk/ts/src/branching.ts | 107 ++++---------- sdk/ts/src/index.ts | 76 ++-------- sdk/ts/src/inspect.ts | 167 ++++++++------------- sdk/ts/src/schemes/ed25519.ts | 43 +++++- sdk/ts/src/schemes/eip191.ts | 54 ++++++- sdk/ts/src/schemes/falcon512.ts | 58 ++++++++ sdk/ts/src/schemes/hawk512.ts | 76 ++++++++++ sdk/ts/src/schemes/secp256k1.ts | 43 +++++- sdk/ts/src/vector.ts | 228 ++++++----------------------- sdk/ts/test/branching.unit.test.ts | 4 +- sdk/ts/test/inspect.unit.test.ts | 24 +-- sdk/ts/test/vector.test.ts | 17 ++- sdk/ts/test/vector.unit.test.ts | 54 +++---- sdk/ts/test/wallet.test.ts | 7 +- 15 files changed, 543 insertions(+), 612 deletions(-) diff --git a/sdk/ts/README.md b/sdk/ts/README.md index 199f25f..4183701 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -10,92 +10,72 @@ be reordered, skipped, or partially replayed. bun add vector-sdk # or npm / pnpm ``` -## The front door: `Vector` +## Import model -A `Vector` is bound once to a signing key. It computes its identity and PDA up -front, and lets you authorize work in terms of plain Solana **instructions** — -the CPIs you want executed under the account's PDA. The SDK wires the `advance` -+ `passthrough` and the transaction layout for you. +The default entrypoint is light — it pulls **no per-scheme crypto**. You +construct a `Vector` from its scheme's subpath, so you only install and bundle +the crypto you actually use. Importing a scheme subpath also registers its +offline verifier with `verifyArtifact`. + +| Subpath | Pulls | Constructor | +| :-- | :-- | :-- | +| `vector-sdk/ed25519` | `@noble/curves/ed25519` | `vectorEd25519(seed)` | +| `vector-sdk/secp256k1` | `@noble/curves/secp256k1` | `vectorSecp256k1(privKey)` | +| `vector-sdk/eip191` | `@noble/curves/secp256k1` + keccak | `vectorEip191(privKey)` | +| `vector-sdk/falcon512` | `@noble/post-quantum` | `vectorFalcon512(keypair)` | +| `vector-sdk/hawk512` | `@blueshift-gg/hawk512` | `vectorHawk512(keypair)` | + +`identity` is the pubkey/address for ed25519/secp256k1/eip191, and +`sha256(wire pubkey)` for the post-quantum schemes. `eip191` lets you sign with +any Ethereum `personal_sign` wallet. Core (`import … from "vector-sdk"`) gives +you the `Vector` type, inspection, migration, and the scanner; the low-level +chain engine is at `vector-sdk/branching`. + +## Quickstart ```ts -import { Vector } from "vector-sdk"; -import { Connection, Keypair, sendAndConfirmTransaction } from "@solana/web3.js"; +import { vectorEd25519 } from "vector-sdk/ed25519"; +import { Connection, sendAndConfirmTransaction } from "@solana/web3.js"; const connection = new Connection("https://api.devnet.solana.com"); -const v = Vector.ed25519(signingKey, { feePayer: relayer.address }); +const v = vectorEd25519(signingKey, { feePayer: relayer.address }); v.identity; // 32-byte pubkey v.pda; // the account's on-chain address -``` - -### One-time setup -```ts +// one-time setup await sendAndConfirmTransaction( connection, new Transaction().add(v.initialize(payer.address)), [payer] ); -``` - -### Authorize one action - -An **op** is the CPI(s) to run under the PDA — a single instruction or a list: - -```ts -const nonce = await v.nonce(connection); // read current state -const art = v.authorize(nonce, withdrawIx); // op: Instruction | Instruction[] -// `art` is broadcast-ready; the relayer adds blockhash + signs as fee payer: +// authorize one action — op = Instruction | Instruction[] (the CPIs to run under the PDA) +const nonce = await v.nonce(connection); // the only async call +const art = v.authorize(nonce, withdrawIx); await sendAndConfirmTransaction(connection, art.transaction(), [relayer]); ``` -An `Artifact` is what every builder returns: - -```ts -art.nonce // the nonce it's bound to (valid only while the PDA sits here) -art.nextNonce // the nonce after it executes -art.instructions // [advance] or [advance, passthrough] -art.transaction() // a fresh Transaction of those instructions -``` +An `Artifact` is what every builder returns: `art.nonce` / `art.nextNonce`, +`art.instructions` (`[...pre, advance, ...passthrough]`), `art.transaction()`, +and `art.advanceIndex` (where the advance sits — 0 except when the scheme +prepends, e.g. Hawk's compute-budget bump). ## Three strategies -### Sequence — `chain` (ordered, forward-secure) - -Each op can only execute after the previous one. Reordering or skipping fails -verification, so a pre-signed batch executes in exactly the order you signed. - ```ts -const steps = v.chain(nonce, [opA, opB, opC]); // Artifact[] -// broadcast steps[0], then steps[1], then steps[2] -``` - -### Alternatives — `branch` (sign N, execute one) +// SEQUENCE — ordered, forward-secure (each op only executes after the prior) +const steps = v.chain(nonce, [opA, opB, opC]); -All branches share one parent state, so whichever lands first orphans the rest -**atomically** — ideal for "settle or cancel" and clean abandonment. +// ALTERNATIVES — sign N, execute one; the rest orphan atomically +const { settle, cancel } = v.branch(nonce, { settle: paymentIx, cancel: [] }); // [] = inert -```ts -const { settle, cancel } = v.branch(nonce, { - settle: paymentIx, - cancel: [], // empty op = inert advance (no side effects) -}); -await sendAndConfirmTransaction(connection, settle.transaction(), [relayer]); -// `cancel` is now permanently dead. +// PARALLEL — independent sub-accounts (one per RFQ deal), same API, own chain +const pos0 = v.derive(0); ``` -### Parallel — `derive` (independent sub-accounts) - -For non-exclusive parallel work (e.g. many simultaneous RFQ positions), derive -sub-accounts. Each is another `Vector` with the same API, its own chain, and a -distinct, deterministic identity/PDA. - -```ts -const position0 = v.derive(0); -const position1 = v.derive(1); -await position1.authorize(nonce1, []); // revoke just position 1 -``` +`derive` works for the 32-byte-key schemes (ed25519 / secp256k1 / eip191); +post-quantum sub-accounts are built from their own keypairs. ## Revocation @@ -111,27 +91,16 @@ await sendAndConfirmTransaction( ); ``` -## Checking validity - -```ts -const status = v.status(steps, await v.nonce(connection)); -// { state: "pending", nextStepIndex } | { state: "completed" } | { state: "orphaned" } -``` - -An artifact is broadcastable iff its chain is still at the nonce it was signed -against. - ## Inspecting & verifying artifacts Artifacts are transportable and self-describing — a counterparty or policy engine can decode the intent and verify the signature **without a chain -connection**. +connection**. `verifyArtifact` works for any scheme whose subpath you've +imported (importing `vector-sdk/ed25519` registers Ed25519, etc.). ```ts -import { - serializeArtifact, deserializeArtifact, - summarize, verifyArtifact, review, -} from "vector-sdk"; +import { serializeArtifact, deserializeArtifact, summarize, verifyArtifact, review } from "vector-sdk"; +import "vector-sdk/ed25519"; // registers the Ed25519 verifier const wire = serializeArtifact(art); // deterministic JSON for transport const a = deserializeArtifact(wire); @@ -141,10 +110,8 @@ verifyArtifact(a); // true | false — recompute digest + check signature, off console.log(review(a)); // deterministic, human-readable block for sign-off ``` -`verifyArtifact` covers all five facade schemes (Ed25519, secp256k1, EIP-191, -Falcon-512, Hawk-512 — the post-quantum artifacts carry their wire pubkey). -Unknown programs are rendered raw (program id + byte/account counts), never -silently hidden — so a reviewer always sees the full intent. +Verification covers all five schemes (the post-quantum artifacts carry their +wire pubkey). Unknown programs are rendered raw — never silently hidden. ## Migration & the scanner (fund-in-PDA) @@ -156,74 +123,52 @@ cutoff. ```ts import { createMigrateSolInstruction, createPdaAtaInstruction, - associatedTokenAddress, createSplTransferIx, - scanMigration, + associatedTokenAddress, createSplTransferIx, scanMigration, } from "vector-sdk"; -// spend SOL out of the PDA — facade convenience for the program's own withdraw -v.withdraw(nonce, to, 1_000n); - -// spend tokens out of the PDA's ATA +v.withdraw(nonce, to, 1_000n); // spend SOL out of the PDA const source = associatedTokenAddress(mint, v.pda); -v.authorize(nonce, createSplTransferIx(source, destAta, v.pda, 50n)); +v.authorize(nonce, createSplTransferIx(source, destAta, v.pda, 50n)); // spend tokens -// audit: did everything leave the old key? const report = await scanMigration(connection, { owner: oldKey, // the keypair you're migrating away from - pda: v.pda, // the Vector account it should now point at + pda: v.pda, mints: [usdcMint], // declared — mint/freeze authorities aren't queryable by authority }); -if (!report.complete) console.table(report.unmigrated); // your migration to-do list +if (!report.complete) console.table(report.unmigrated); // your migration to-do list ``` -The scanner **auto-discovers** what Solana can index by authority — native SOL, -SPL + Token-2022 accounts, and stake accounts. Mint/freeze authorities and -arbitrary program authorities are **not** indexed by authority, so you declare -them (`mints`, `accounts`) and the scanner verifies each one points at the PDA. +The scanner auto-discovers native SOL, SPL + Token-2022 accounts, and stake +accounts; declared mints/accounts are verified to point at the PDA. `report.complete` is true only when nothing controllable remains on the old key. +## Registration + +`register(payer)` returns the account-creation transactions in order — one +instruction-group per transaction — and is the uniform path across schemes. +Single-transaction schemes return one group (and offer `initialize(payer)` as a +shorthand); **Hawk-512** returns three (`initialize` → `storeWire` → +`finalize`, its prepared pubkey is ~18 KB), and its advance carries a +compute-budget bump committed to by the digest — both handled by the facade. + ## Air-gapped signing -Signing is **synchronous and offline** — `authorize`/`chain`/`branch` take a -nonce and never touch the network. Only `nonce()` reads on-chain. For a cold +Signing is **synchronous and offline** — `authorize` / `chain` / `branch` take +a nonce and never touch the network; only `nonce()` reads on-chain. For a cold ceremony: read the nonce online, carry it to the air-gapped signer, sign there, carry the artifact back out for the relayer to broadcast. ```ts -// online watcher -const nonce = await v.nonce(connection); -// air-gapped signer (no connection) -const art = Vector.ed25519(coldKey, { feePayer }).authorize(nonce, withdrawIx); +const nonce = await watcher.nonce(connection); // online +const art = vectorEd25519(coldKey, { feePayer }).authorize(nonce, withdrawIx); // air-gapped ``` ## Low-level engine `Vector` is a facade over composable primitives in `vector-sdk/branching` (`signChain`, `signBranches`, `resolveChainStatus`, `ChainSigner`, -`deriveLaneSeed`) and the instruction builders in `vector-sdk` (`createAdvanceInstruction`, -`createPassthroughInstruction`, `advanceVectorDigest`, …). Reach for these only -when you need control the facade doesn't expose (custom pre/post instructions, -non-Ed25519 schemes, bespoke digest handling). - -## Schemes - -The facade covers five schemes — same API, different `Vector.*` constructor: - -| Constructor | Key material | Identity | -| :-- | :-- | :-- | -| `Vector.ed25519(seed)` | 32-byte Ed25519 seed | 32-byte public key | -| `Vector.secp256k1(privKey)` | 32-byte secp256k1 key | 33-byte compressed pubkey | -| `Vector.eip191(privKey)` | 32-byte secp256k1 key | 20-byte Ethereum address — sign with any `personal_sign` wallet | -| `Vector.falcon512(keypair)` | Falcon-512 keypair | `sha256(wire pubkey)` (post-quantum) | -| `Vector.hawk512(keypair)` | Hawk-512 keypair | `sha256(wire pubkey)` (post-quantum) | - -`derive(i)` works for the 32-byte-key schemes (ed25519 / secp256k1 / eip191); -post-quantum sub-accounts are built from their own keypairs. - -**Registration.** `register(payer)` returns the account-creation transactions -in order — one instruction-group per transaction — and is the uniform path -across schemes. Single-transaction schemes return one group (and offer -`initialize(payer)` as a shorthand); **Hawk-512** returns three (`initialize` → -`storeWire` → `finalize`, its prepared pubkey is ~18 KB), and its advance -carries a compute-budget bump committed to by the digest — both handled by the -facade. +`deriveLaneSeed`) and the instruction builders in `vector-sdk` +(`createAdvanceInstruction`, `createPassthroughInstruction`, +`advanceVectorDigest`, …). Reach for these only when you need control the +facade doesn't expose (custom pre/post instructions, bespoke digest handling). +``` diff --git a/sdk/ts/src/branching.ts b/sdk/ts/src/branching.ts index 7080ab3..bcb5ba3 100644 --- a/sdk/ts/src/branching.ts +++ b/sdk/ts/src/branching.ts @@ -10,37 +10,9 @@ * be explicit about the exact sequence(s) of events that may occur. */ import { Address, Connection, TransactionInstruction } from "@solana/web3.js"; -import { hkdf } from "@noble/hashes/hkdf"; -import { sha256 } from "@noble/hashes/sha256"; +import { createHmac } from "crypto"; import { Scheme, fetchVectorAccount } from "./scheme.js"; -import { - ED25519, - ed25519Identity, - signAdvanceInstructionEd25519, -} from "./schemes/ed25519.js"; -import { - SECP256K1, - secp256k1Identity, - signAdvanceInstructionSecp256k1, -} from "./schemes/secp256k1.js"; -import { - EIP191, - eip191Identity, - signAdvanceInstructionEip191, -} from "./schemes/eip191.js"; -import { - FALCON512, - falcon512Identity, - signAdvanceInstructionFalcon512, - Falcon512Keypair, -} from "./schemes/falcon512.js"; -import { - HAWK512, - hawk512Identity, - signAdvanceInstructionHawk512, - Hawk512Keypair, -} from "./schemes/hawk512.js"; import { advanceVectorDigest } from "./digest.js"; /** Per-scheme signer for a single chain (one key / identity). */ @@ -56,56 +28,6 @@ export interface ChainSigner { ): TransactionInstruction; } -/** Ed25519 chain signer bound to a 32-byte private-key seed. */ -export function ed25519ChainSigner(signingKey: Uint8Array): ChainSigner { - return { - scheme: ED25519, - identity: ed25519Identity(signingKey), - sign: (nonce, pre, post, feePayer) => - signAdvanceInstructionEd25519(signingKey, nonce, pre, post, feePayer), - }; -} - -/** Plain secp256k1 ECDSA chain signer (32-byte private key). */ -export function secp256k1ChainSigner(privateKey: Uint8Array): ChainSigner { - return { - scheme: SECP256K1, - identity: secp256k1Identity(privateKey), - sign: (nonce, pre, post, feePayer) => - signAdvanceInstructionSecp256k1(privateKey, nonce, pre, post, feePayer), - }; -} - -/** EIP-191 (Ethereum) chain signer (32-byte secp256k1 private key). */ -export function eip191ChainSigner(privateKey: Uint8Array): ChainSigner { - return { - scheme: EIP191, - identity: eip191Identity(privateKey), - sign: (nonce, pre, post, feePayer) => - signAdvanceInstructionEip191(privateKey, nonce, pre, post, feePayer), - }; -} - -/** Falcon-512 (post-quantum) chain signer (1281-byte secret + 897-byte wire pubkey). */ -export function falcon512ChainSigner(keypair: Falcon512Keypair): ChainSigner { - return { - scheme: FALCON512, - identity: falcon512Identity(keypair.publicKey), - sign: (nonce, pre, post, feePayer) => - signAdvanceInstructionFalcon512(keypair, nonce, pre, post, feePayer), - }; -} - -/** Hawk-512 (post-quantum) chain signer (184-byte secret + 1024-byte wire pubkey). */ -export function hawk512ChainSigner(keypair: Hawk512Keypair): ChainSigner { - return { - scheme: HAWK512, - identity: hawk512Identity(keypair.publicKey), - sign: (nonce, pre, post, feePayer) => - signAdvanceInstructionHawk512(keypair, nonce, pre, post, feePayer), - }; -} - /** One step in a chain: instructions placed around the advance. */ export interface BranchStep { /** Top-level ixs before the advance (committed to by the digest). */ @@ -263,6 +185,31 @@ export async function fetchChainStatus( /** Domain-separation salt for sub-account derivation; bumping it re-derives. */ export const LANE_KDF_SALT = new TextEncoder().encode("vector-lane-kdf-v1"); +/** Native HKDF-SHA256 (RFC 5869) — same output as `@noble/hashes`, no dep. */ +function hkdfSha256( + ikm: Uint8Array, + salt: Uint8Array, + info: Uint8Array, + length: number +): Uint8Array { + const prk = createHmac("sha256", Buffer.from(salt)) + .update(Buffer.from(ikm)) + .digest(); + const out = Buffer.alloc(length); + let t = Buffer.alloc(0); + let pos = 0; + for (let counter = 1; pos < length; counter++) { + t = createHmac("sha256", prk) + .update(t) + .update(Buffer.from(info)) + .update(Buffer.from([counter])) + .digest(); + t.copy(out, pos); + pos += t.length; + } + return new Uint8Array(out.subarray(0, length)); +} + /** * Deterministic 32-byte child seed for an independent sub-account, derived * from a master seed via HKDF-SHA256 (domain-separated by scheme + index). @@ -277,5 +224,5 @@ export function deriveLaneSeed( throw new Error(`index must be a non-negative integer, got ${index}`); } const info = new TextEncoder().encode(`vector-lane:${schemeName}:${index}`); - return hkdf(sha256, masterSeed, LANE_KDF_SALT, info, 32); + return hkdfSha256(masterSeed, LANE_KDF_SALT, info, 32); } diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index d834b9f..43b108b 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -1,77 +1,27 @@ /** - * Off-chain helpers for constructing Vector program instructions and - * computing the digests the on-chain programs verify. + * Vector SDK — core (scheme-agnostic) entrypoint. * - * Each signing scheme is its own on-chain program with its own program ID. - * There is no on-chain scheme discriminator: the program ID identifies the - * scheme, the account header is `nonce[32] || bump[1]` (33 bytes), and PDA - * seeds are `["vector", identity_seed]`. A {@link Scheme} bundles what a - * client needs to talk to a given program: its program ID, wire signature - * length, and identity/stored-identity lengths. + * The default import is light: it pulls no per-scheme crypto. Construct a + * `Vector` from its scheme's subpath so you only install/bundle the crypto you + * use, e.g. `import { vectorEd25519 } from "vector-sdk/ed25519"`. Importing a + * scheme subpath also registers its offline verifier with {@link verifyArtifact}. * - * # Layout + * Per-scheme subpaths — each exports its `Scheme` const, identity/init/sign + * builders, a `vector(...)` constructor, and a `ChainSigner`: * - * - `./scheme.js` — the {@link Scheme} descriptor, {@link VectorAccount} - * header mirror, canonical PDA derivation, and shared byte helpers. - * - `./instructions.js` — generic builders (initialize/advance/passthrough/ - * close/withdraw, instructions-sysvar serialization). - * - `./digest.js` — {@link advanceVectorDigest}, the value clients sign. - * - `./schemes/*.js` — one module per program (`ed25519`, `eip191`, - * `falcon512`, `hawk512`, `secp256k1`): its `Scheme`/program-ID const, - * identity derivation, an `initialize` builder, and a signer where one - * exists. + * - `vector-sdk/ed25519` + * - `vector-sdk/secp256k1` + * - `vector-sdk/eip191` + * - `vector-sdk/falcon512` + * - `vector-sdk/hawk512` * - * Everything is re-exported flat here, so either style works: - * - * ```ts - * import { ED25519, signAdvanceInstructionEd25519 } from "vector-sdk"; // flat - * import { ED25519, signAdvanceInstructionEd25519 } from "vector-sdk/ed25519"; // per-scheme - * ``` - * - * The Falcon/Hawk wire-size constants are owned by `./scheme.js` and - * re-exported flat from there. `./schemes/falcon512.js` and - * `./schemes/hawk512.js` also re-export them for their standalone subpath - * entrypoints; to keep the flat API unambiguous they are NOT glob-exported - * here from those modules (only the symbols unique to each scheme module - * are). + * The low-level chain engine is at `vector-sdk/branching`. */ - export * from "./scheme.js"; export * from "./instructions.js"; export * from "./digest.js"; - export * from "./vector.js"; export * from "./inspect.js"; export * from "./wallet.js"; export * from "./migrate.js"; export * from "./scan.js"; - -export * from "./schemes/ed25519.js"; -export * from "./schemes/eip191.js"; -export * from "./schemes/secp256k1.js"; - -// Falcon/Hawk: re-export only the scheme-unique symbols. The wire-size -// constants come from `./scheme.js` above (re-exporting them again via -// these modules would make them ambiguous and silently drop them from the -// flat barrel). -export { - FALCON512, - FALCON_SECRET_KEY_LEN, - falcon512Identity, - falcon512Keygen, - falcon512PublicKey, - createInitializeFalcon512, - signAdvanceInstructionFalcon512, -} from "./schemes/falcon512.js"; -export type { Falcon512Keypair } from "./schemes/falcon512.js"; -export { - HAWK512, - HAWK_SECRET_KEY_LEN, - hawk512Identity, - hawk512Keygen, - createInitializeHawk512, - createHawk512StoreWire, - createHawk512Finalize, - signAdvanceInstructionHawk512, -} from "./schemes/hawk512.js"; -export type { Hawk512Keypair } from "./schemes/hawk512.js"; diff --git a/sdk/ts/src/inspect.ts b/sdk/ts/src/inspect.ts index 79467e4..97f4c1b 100644 --- a/sdk/ts/src/inspect.ts +++ b/sdk/ts/src/inspect.ts @@ -1,30 +1,23 @@ /** * Inspect, serialize, and offline-verify Vector {@link Artifact}s — so policy - * engines and air-gapped reviewers see *intent* instead of signing an opaque - * blob, and so a counterparty can transport an artifact and check it without a - * chain connection. + * engines and air-gapped reviewers see *intent* instead of an opaque blob, and + * a counterparty can transport an artifact and check it without a chain. + * + * This module is **crypto-free**. Offline verification is dispatched through a + * registry that each scheme subpath module populates on import — so + * `verifyArtifact` only pulls the crypto for the schemes you actually import + * (e.g. importing `vector-sdk/ed25519` registers Ed25519 verification). */ import { Address, Transaction, TransactionInstruction } from "@solana/web3.js"; -import { ed25519 } from "@noble/curves/ed25519"; -import { secp256k1 } from "@noble/curves/secp256k1"; -import { keccak_256 } from "@noble/hashes/sha3"; -import { falcon512 as nobleFalcon } from "@noble/post-quantum/falcon.js"; -import { hawk512 as nobleHawk } from "@blueshift-gg/hawk512"; - import { Scheme, readU16LE, ADVANCE_DISCRIMINATOR, - PASSTHROUGH_DISCRIMINATOR, CLOSE_DISCRIMINATOR, WITHDRAW_DISCRIMINATOR, + PASSTHROUGH_DISCRIMINATOR, } from "./scheme.js"; import { advanceVectorDigest } from "./digest.js"; -import { ED25519 } from "./schemes/ed25519.js"; -import { SECP256K1 } from "./schemes/secp256k1.js"; -import { EIP191 } from "./schemes/eip191.js"; -import { FALCON512 } from "./schemes/falcon512.js"; -import { HAWK512 } from "./schemes/hawk512.js"; import { Artifact } from "./vector.js"; const toHex = (b: Uint8Array): string => Buffer.from(b).toString("hex"); @@ -33,15 +26,6 @@ const fromHex = (s: string): Uint8Array => new Uint8Array(Buffer.from(s, "hex")) const SYSTEM_PROGRAM = "11111111111111111111111111111111"; const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; -/** Resolve a {@link Scheme} from its on-chain program id. */ -export function schemeForProgramId(programId: Address | string): Scheme { - const id = typeof programId === "string" ? programId : programId.toBase58(); - for (const s of [ED25519, SECP256K1, EIP191, FALCON512, HAWK512]) { - if (s.programId.toBase58() === id) return s; - } - throw new Error(`unknown scheme program id: ${id}`); -} - // ── Serialization ──────────────────────────────────────────────────── interface SerializedKey { @@ -188,108 +172,73 @@ export function summarize(a: Artifact): string[] { }); } -// ── Offline verification ───────────────────────────────────────────── +/** A deterministic, human-readable review block for a policy engine / display. */ +export function review(a: Artifact): string { + const lines = ["VECTOR ARTIFACT"]; + lines.push(`scheme: ${a.programId.toBase58()}`); + lines.push(`identity: ${toHex(a.identity).slice(0, 8)}…`); + lines.push(`nonce: ${toHex(a.nonce).slice(0, 8)}…`); + if (a.feePayer) lines.push(`fee payer: ${a.feePayer.toBase58()}`); + lines.push("intent:"); + for (const line of summarize(a)) lines.push(` - ${line}`); + return lines.join("\n"); +} -const EIP191_PREFIX = new TextEncoder().encode("\x19Ethereum Signed Message:\n32"); +// ── Offline verification (registry) ────────────────────────────────── -function eip191Hash(digest: Uint8Array): Uint8Array { - const buf = new Uint8Array(EIP191_PREFIX.length + digest.length); - buf.set(EIP191_PREFIX); - buf.set(digest, EIP191_PREFIX.length); - return keccak_256(buf); -} +/** Recompute-and-check verifier for one scheme's artifacts. */ +export type ArtifactVerifier = (artifact: Artifact) => boolean; -function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - let d = 0; - for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; - return d === 0; +const verifiers = new Map(); + +/** + * Register a scheme's offline verifier. Called at module load by each scheme + * subpath (e.g. importing `vector-sdk/ed25519` registers Ed25519), so + * {@link verifyArtifact} only pulls the crypto for imported schemes. + */ +export function registerArtifactVerifier( + programId: Address, + verify: ArtifactVerifier +): void { + verifiers.set(programId.toBase58(), verify); } /** - * Recompute the canonical digest from the artifact's instruction layout and - * verify the signature offline — no chain access. Supports the four facade - * schemes (Ed25519, secp256k1, EIP-191, Falcon-512). Returns `false` for a - * bad signature; **throws** for a scheme it can't verify offline (Hawk-512) - * or a Falcon artifact missing its `publicKey`. Assumes facade-shaped - * artifacts (`[advance, ...passthrough]`). + * Verify an artifact's signature offline. Throws if no verifier is registered + * for its scheme — import the scheme module (e.g. `vector-sdk/ed25519`) to + * register it. */ export function verifyArtifact(a: Artifact): boolean { - const scheme = schemeForProgramId(a.programId); - const pid = scheme.programId.toBase58(); - - const needsPubkey = - pid === FALCON512.programId.toBase58() || pid === HAWK512.programId.toBase58(); - if (needsPubkey && !a.publicKey) { + const verify = verifiers.get(a.programId.toBase58()); + if (!verify) { throw new Error( - "verifyArtifact: post-quantum artifact is missing publicKey (the wire pubkey)" + `verifyArtifact: no verifier registered for ${a.programId.toBase58()} — import its scheme module (e.g. "vector-sdk/ed25519")` ); } + return verify(a); +} - const idx = a.advanceIndex ?? 0; - const advanceData = new Uint8Array(a.instructions[idx].data); - if (advanceData[0] !== ADVANCE_DISCRIMINATOR) return false; +/** + * Shared helper for scheme verifiers: pull the advance signature and recompute + * the canonical digest from a facade-shaped artifact (`[...pre, advance, + * ...post]`). Returns `null` if the advance isn't an advance instruction. + * Uses only native SHA-256 (via {@link advanceVectorDigest}) — no scheme + * crypto — so this module stays free of the per-scheme libraries. + */ +export function artifactParts( + a: Artifact, + scheme: Scheme +): { signature: Uint8Array; digest: Uint8Array } | null { + const advanceData = new Uint8Array(a.instructions[a.advanceIndex].data); + if (advanceData[0] !== ADVANCE_DISCRIMINATOR) return null; const signature = advanceData.slice(1, 1 + scheme.signatureLen); const digest = advanceVectorDigest( scheme, a.nonce, a.identity, - a.instructions.slice(0, idx), - a.instructions.slice(idx + 1), + a.instructions.slice(0, a.advanceIndex), + a.instructions.slice(a.advanceIndex + 1), a.feePayer ); - - try { - if (pid === ED25519.programId.toBase58()) { - return ed25519.verify(signature, digest, a.identity); - } - if (pid === SECP256K1.programId.toBase58()) { - return secp256k1.verify(signature, digest, a.identity); - } - if (pid === HAWK512.programId.toBase58()) { - // Hawk signature is fixed-size — no length recovery needed (unlike Falcon). - return nobleHawk.verify(signature, digest, a.publicKey!); - } - if (pid === EIP191.programId.toBase58()) { - const recovered = secp256k1.Signature.fromCompact(signature.slice(0, 64)) - .addRecoveryBit(signature[64]) - .recoverPublicKey(eip191Hash(digest)) - .toRawBytes(false); // 65-byte uncompressed: 0x04 || x || y - return bytesEqual(keccak_256(recovered.slice(1)).slice(12, 32), a.identity); - } - // Falcon-512: identity is sha256(wire), so verify against the wire pubkey. - // The on-chain wire zero-pads the compressed signature to a fixed size, - // but noble needs its exact detached length. Recover it by scanning from - // the last non-zero byte up to the padded size — exactly one length - // verifies (shorter truncates, longer carries trailing padding noble - // rejects). - let end = signature.length; - while (end > 0 && signature[end - 1] === 0) end--; - for (let len = end; len <= signature.length; len++) { - try { - if (nobleFalcon.verify(signature.slice(0, len), digest, a.publicKey!)) { - return true; - } - } catch { - // wrong length — keep scanning - } - } - return false; - } catch { - return false; - } -} - -// ── Review rendering ───────────────────────────────────────────────── - -/** A deterministic, human-readable review block for a policy engine / display. */ -export function review(a: Artifact): string { - const lines = ["VECTOR ARTIFACT"]; - lines.push(`scheme: ${a.programId.toBase58()}`); - lines.push(`identity: ${toHex(a.identity).slice(0, 8)}…`); - lines.push(`nonce: ${toHex(a.nonce).slice(0, 8)}…`); - if (a.feePayer) lines.push(`fee payer: ${a.feePayer.toBase58()}`); - lines.push("intent:"); - for (const line of summarize(a)) lines.push(` - ${line}`); - return lines.join("\n"); + return { signature, digest }; } diff --git a/sdk/ts/src/schemes/ed25519.ts b/sdk/ts/src/schemes/ed25519.ts index 18a0647..63de443 100644 --- a/sdk/ts/src/schemes/ed25519.ts +++ b/sdk/ts/src/schemes/ed25519.ts @@ -8,12 +8,15 @@ import { Address, TransactionInstruction } from "@solana/web3.js"; import { ed25519 } from "@noble/curves/ed25519"; -import { Scheme } from "../scheme.js"; +import { Scheme, findVectorPda } from "../scheme.js"; import { createInitializeInstruction, createAdvanceInstruction, } from "../instructions.js"; import { advanceVectorDigest } from "../digest.js"; +import { Vector, Artifact } from "../vector.js"; +import { ChainSigner, deriveLaneSeed } from "../branching.js"; +import { registerArtifactVerifier, artifactParts } from "../inspect.js"; /** Ed25519 — identity is the 32-byte public key. */ export const ED25519: Scheme = { @@ -60,3 +63,41 @@ export function signAdvanceInstructionEd25519( const signature = ed25519.sign(digest, signingKey); return createAdvanceInstruction(ED25519, identity, signature); } + +/** Ed25519 chain signer bound to a 32-byte private-key seed. */ +export function ed25519ChainSigner(signingKey: Uint8Array): ChainSigner { + return { + scheme: ED25519, + identity: ed25519Identity(signingKey), + sign: (nonce, pre, post, feePayer) => + signAdvanceInstructionEd25519(signingKey, nonce, pre, post, feePayer), + }; +} + +/** Construct an Ed25519 {@link Vector} from a 32-byte private-key seed. */ +export function vectorEd25519( + signingKey: Uint8Array, + opts?: { feePayer?: Address } +): Vector { + const signer = ed25519ChainSigner(signingKey); + const [pda] = findVectorPda(ED25519, signer.identity); + return Vector.fromParts({ + scheme: ED25519, + signer, + pda, + initIx: (payer) => createInitializeEd25519(payer, signer.identity), + feePayer: opts?.feePayer, + deriveChild: (i) => + vectorEd25519(deriveLaneSeed(signingKey, "ed25519", i), opts), + }); +} + +registerArtifactVerifier(ED25519.programId, (a: Artifact) => { + const parts = artifactParts(a, ED25519); + if (!parts) return false; + try { + return ed25519.verify(parts.signature, parts.digest, a.identity); + } catch { + return false; + } +}); diff --git a/sdk/ts/src/schemes/eip191.ts b/sdk/ts/src/schemes/eip191.ts index c4e35ef..653a6ae 100644 --- a/sdk/ts/src/schemes/eip191.ts +++ b/sdk/ts/src/schemes/eip191.ts @@ -10,12 +10,15 @@ import { Address, TransactionInstruction } from "@solana/web3.js"; import { secp256k1 } from "@noble/curves/secp256k1"; import { keccak_256 } from "@noble/hashes/sha3"; -import { Scheme } from "../scheme.js"; +import { Scheme, findVectorPda } from "../scheme.js"; import { createInitializeInstruction, createAdvanceInstruction, } from "../instructions.js"; import { advanceVectorDigest } from "../digest.js"; +import { Vector, Artifact } from "../vector.js"; +import { ChainSigner, deriveLaneSeed } from "../branching.js"; +import { registerArtifactVerifier, artifactParts } from "../inspect.js"; /** secp256k1 ECDSA + EIP-191 envelope — identity is the 20-byte ETH address. */ export const EIP191: Scheme = { @@ -87,3 +90,52 @@ export function signAdvanceInstructionEip191( return createAdvanceInstruction(EIP191, identity, sigBytes); } + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let d = 0; + for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; + return d === 0; +} + +/** EIP-191 (Ethereum) chain signer (32-byte secp256k1 private key). */ +export function eip191ChainSigner(privateKey: Uint8Array): ChainSigner { + return { + scheme: EIP191, + identity: eip191Identity(privateKey), + sign: (nonce, pre, post, feePayer) => + signAdvanceInstructionEip191(privateKey, nonce, pre, post, feePayer), + }; +} + +/** Construct an EIP-191 {@link Vector} from a 32-byte secp256k1 private key. */ +export function vectorEip191( + privateKey: Uint8Array, + opts?: { feePayer?: Address } +): Vector { + const signer = eip191ChainSigner(privateKey); + const [pda] = findVectorPda(EIP191, signer.identity); + return Vector.fromParts({ + scheme: EIP191, + signer, + pda, + initIx: (payer) => createInitializeEip191(payer, signer.identity), + feePayer: opts?.feePayer, + deriveChild: (i) => + vectorEip191(deriveLaneSeed(privateKey, "eip191", i), opts), + }); +} + +registerArtifactVerifier(EIP191.programId, (a: Artifact) => { + const parts = artifactParts(a, EIP191); + if (!parts) return false; + try { + const recovered = secp256k1.Signature.fromCompact(parts.signature.slice(0, 64)) + .addRecoveryBit(parts.signature[64]) + .recoverPublicKey(eip191Hash(parts.digest)) + .toRawBytes(false); // 65-byte uncompressed: 0x04 || x || y + return bytesEqual(keccak_256(recovered.slice(1)).slice(12, 32), a.identity); + } catch { + return false; + } +}); diff --git a/sdk/ts/src/schemes/falcon512.ts b/sdk/ts/src/schemes/falcon512.ts index 6d36222..6bc69f7 100644 --- a/sdk/ts/src/schemes/falcon512.ts +++ b/sdk/ts/src/schemes/falcon512.ts @@ -17,6 +17,7 @@ import { falcon512 as nobleFalcon } from "@noble/post-quantum/falcon.js"; import { Scheme, + findVectorPda, sha256, FALCON_PUBKEY_LEN, FALCON_SIGNATURE_LEN, @@ -27,6 +28,9 @@ import { createAdvanceInstruction, } from "../instructions.js"; import { advanceVectorDigest } from "../digest.js"; +import { Vector, Artifact } from "../vector.js"; +import { ChainSigner } from "../branching.js"; +import { registerArtifactVerifier, artifactParts } from "../inspect.js"; export { FALCON_PUBKEY_LEN, @@ -123,3 +127,57 @@ export function signAdvanceInstructionFalcon512( return createAdvanceInstruction(FALCON512, identity, signature); } + +/** Falcon-512 (post-quantum) chain signer (1281-byte secret + 897-byte wire pubkey). */ +export function falcon512ChainSigner(keypair: Falcon512Keypair): ChainSigner { + return { + scheme: FALCON512, + identity: falcon512Identity(keypair.publicKey), + sign: (nonce, pre, post, feePayer) => + signAdvanceInstructionFalcon512(keypair, nonce, pre, post, feePayer), + }; +} + +/** + * Construct a Falcon-512 {@link Vector} from a keypair. `derive` is unavailable + * (Falcon has no 32-byte seed) — build each sub-account from its own keypair. + */ +export function vectorFalcon512( + keypair: Falcon512Keypair, + opts?: { feePayer?: Address } +): Vector { + const signer = falcon512ChainSigner(keypair); + const [pda] = findVectorPda(FALCON512, signer.identity); + return Vector.fromParts({ + scheme: FALCON512, + signer, + pda, + initIx: (payer) => createInitializeFalcon512(payer, keypair.publicKey), + feePayer: opts?.feePayer, + publicKey: keypair.publicKey, + }); +} + +registerArtifactVerifier(FALCON512.programId, (a: Artifact) => { + if (!a.publicKey) { + throw new Error( + "verifyArtifact: Falcon artifact is missing publicKey (the wire pubkey)" + ); + } + const parts = artifactParts(a, FALCON512); + if (!parts) return false; + // The on-chain wire zero-pads the compressed signature; noble needs the exact + // detached length. Recover it by scanning from the last non-zero byte up. + let end = parts.signature.length; + while (end > 0 && parts.signature[end - 1] === 0) end--; + for (let len = end; len <= parts.signature.length; len++) { + try { + if (nobleFalcon.verify(parts.signature.slice(0, len), parts.digest, a.publicKey)) { + return true; + } + } catch { + // wrong length — keep scanning + } + } + return false; +}); diff --git a/sdk/ts/src/schemes/hawk512.ts b/sdk/ts/src/schemes/hawk512.ts index f53fd48..c27f6d8 100644 --- a/sdk/ts/src/schemes/hawk512.ts +++ b/sdk/ts/src/schemes/hawk512.ts @@ -30,6 +30,9 @@ import { createAdvanceInstruction, } from "../instructions.js"; import { advanceVectorDigest } from "../digest.js"; +import { Vector, Artifact } from "../vector.js"; +import { ChainSigner } from "../branching.js"; +import { registerArtifactVerifier, artifactParts } from "../inspect.js"; export { HAWK_PUBKEY_LEN, @@ -199,3 +202,76 @@ export function signAdvanceInstructionHawk512( return createAdvanceInstruction(HAWK512, identity, signature); } + +const COMPUTE_BUDGET_PROGRAM_ID = new Address( + "ComputeBudget111111111111111111111111111111" +); + +/** `ComputeBudgetProgram.setComputeUnitLimit` ix, built by hand (disc 2 + u32 LE). */ +function setComputeUnitLimitIx(units: number): TransactionInstruction { + const data = new Uint8Array(5); + data[0] = 2; + data[1] = units & 0xff; + data[2] = (units >>> 8) & 0xff; + data[3] = (units >>> 16) & 0xff; + data[4] = (units >>> 24) & 0xff; + return new TransactionInstruction({ + programId: COMPUTE_BUDGET_PROGRAM_ID, + keys: [], + data: Buffer.from(data), + }); +} + +/** Hawk-512 (post-quantum) chain signer (184-byte secret + 1024-byte wire pubkey). */ +export function hawk512ChainSigner(keypair: Hawk512Keypair): ChainSigner { + return { + scheme: HAWK512, + identity: hawk512Identity(keypair.publicKey), + sign: (nonce, pre, post, feePayer) => + signAdvanceInstructionHawk512(keypair, nonce, pre, post, feePayer), + }; +} + +/** + * Construct a Hawk-512 {@link Vector} from a keypair. Registration is a + * three-transaction flow — call `register(payer)` (not `initialize`). The + * advance carries a compute-budget bump (Hawk verification is heavy), + * committed to by the digest. `derive` is unavailable — build each + * sub-account from its own keypair. + */ +export function vectorHawk512( + keypair: Hawk512Keypair, + opts?: { feePayer?: Address } +): Vector { + const signer = hawk512ChainSigner(keypair); + const [pda] = findVectorPda(HAWK512, signer.identity); + return Vector.fromParts({ + scheme: HAWK512, + signer, + pda, + registerGroups: (payer) => [ + [createInitializeHawk512(payer, keypair.publicKey)], + [createHawk512StoreWire(keypair.publicKey)], + [setComputeUnitLimitIx(600_000), createHawk512Finalize(keypair.publicKey)], + ], + feePayer: opts?.feePayer, + publicKey: keypair.publicKey, + preIxs: [setComputeUnitLimitIx(450_000)], + }); +} + +registerArtifactVerifier(HAWK512.programId, (a: Artifact) => { + if (!a.publicKey) { + throw new Error( + "verifyArtifact: Hawk artifact is missing publicKey (the wire pubkey)" + ); + } + const parts = artifactParts(a, HAWK512); + if (!parts) return false; + try { + // Hawk signature is fixed-size — no length recovery needed. + return nobleHawk.verify(parts.signature, parts.digest, a.publicKey); + } catch { + return false; + } +}); diff --git a/sdk/ts/src/schemes/secp256k1.ts b/sdk/ts/src/schemes/secp256k1.ts index 957ebbf..51afc74 100644 --- a/sdk/ts/src/schemes/secp256k1.ts +++ b/sdk/ts/src/schemes/secp256k1.ts @@ -8,12 +8,15 @@ import { Address, TransactionInstruction } from "@solana/web3.js"; import { secp256k1 } from "@noble/curves/secp256k1"; -import { Scheme, SECP256K1_COMPRESSED_PUBKEY_LEN } from "../scheme.js"; +import { Scheme, findVectorPda, SECP256K1_COMPRESSED_PUBKEY_LEN } from "../scheme.js"; import { createInitializeInstruction, createAdvanceInstruction, } from "../instructions.js"; import { advanceVectorDigest } from "../digest.js"; +import { Vector, Artifact } from "../vector.js"; +import { ChainSigner, deriveLaneSeed } from "../branching.js"; +import { registerArtifactVerifier, artifactParts } from "../inspect.js"; /** Plain secp256k1 ECDSA — identity is the 33-byte compressed pubkey. */ export const SECP256K1: Scheme = { @@ -82,3 +85,41 @@ export function signAdvanceInstructionSecp256k1( return createAdvanceInstruction(SECP256K1, identity, sigBytes); } + +/** Plain secp256k1 ECDSA chain signer (32-byte private key). */ +export function secp256k1ChainSigner(privateKey: Uint8Array): ChainSigner { + return { + scheme: SECP256K1, + identity: secp256k1Identity(privateKey), + sign: (nonce, pre, post, feePayer) => + signAdvanceInstructionSecp256k1(privateKey, nonce, pre, post, feePayer), + }; +} + +/** Construct a plain-secp256k1 {@link Vector} from a 32-byte private key. */ +export function vectorSecp256k1( + privateKey: Uint8Array, + opts?: { feePayer?: Address } +): Vector { + const signer = secp256k1ChainSigner(privateKey); + const [pda] = findVectorPda(SECP256K1, signer.identity); + return Vector.fromParts({ + scheme: SECP256K1, + signer, + pda, + initIx: (payer) => createInitializeSecp256k1(payer, signer.identity), + feePayer: opts?.feePayer, + deriveChild: (i) => + vectorSecp256k1(deriveLaneSeed(privateKey, "secp256k1", i), opts), + }); +} + +registerArtifactVerifier(SECP256K1.programId, (a: Artifact) => { + const parts = artifactParts(a, SECP256K1); + if (!parts) return false; + try { + return secp256k1.verify(parts.signature, parts.digest, a.identity); + } catch { + return false; + } +}); diff --git a/sdk/ts/src/vector.ts b/sdk/ts/src/vector.ts index 048f81a..4981ca2 100644 --- a/sdk/ts/src/vector.ts +++ b/sdk/ts/src/vector.ts @@ -1,35 +1,18 @@ /** - * `Vector` — the front door to the Vector SDK. + * `Vector` — the scheme-agnostic core of the SDK. * - * A `Vector` is bound once to a signing key and computes its on-chain - * identity and PDA up front. You then authorize work in terms of plain - * Solana **instructions** (the CPIs you want executed under the PDA); the - * SDK wires the `advance` + `passthrough` and the transaction layout for you, - * so you never assemble those by hand. + * You authorize work in terms of plain Solana **instructions** (the CPIs to + * run under the PDA); the SDK wires the `advance` + `passthrough` and the + * transaction layout. Three ways to authorize, all returning ready-to- + * broadcast {@link Artifact}s: {@link Vector.authorize} (one op), + * {@link Vector.chain} (ordered, forward-secure), {@link Vector.branch} + * (mutually-exclusive alternatives). {@link Vector.derive} gives independent + * sub-accounts. Signing is synchronous and offline; only {@link Vector.nonce} + * touches the network. * - * Three ways to authorize, all returning ready-to-broadcast {@link Artifact}s: - * - * - {@link Vector.authorize} — one op. - * - {@link Vector.chain} — an ordered, forward-secure sequence (each op can - * only execute after the previous one). - * - {@link Vector.branch} — mutually-exclusive alternatives ("sign N, execute - * one"); whichever lands first orphans the rest. - * - * For independent, non-exclusive parallel work (e.g. many simultaneous RFQ - * positions) derive sub-accounts with {@link Vector.derive} — each is another - * `Vector` with the same API. - * - * Signing is **synchronous and offline** (air-gapped friendly): you pass the - * nonce you signed against. The only networked call is {@link Vector.nonce}, - * which reads the current on-chain nonce. - * - * @example - * ```ts - * const v = Vector.ed25519(key, { feePayer }); - * const nonce = await v.nonce(connection); - * const art = v.authorize(nonce, withdrawIx); // op = Instruction | Instruction[] - * await sendAndConfirmTransaction(connection, art.transaction(), [feePayer]); - * ``` + * Construct a `Vector` with a per-scheme constructor from its subpath so you + * only pull the crypto you use, e.g. + * `import { vectorEd25519 } from "vector-sdk/ed25519"`. */ import { Address, @@ -38,22 +21,7 @@ import { TransactionInstruction, } from "@solana/web3.js"; -import { Scheme, findVectorPda, fetchVectorAccount } from "./scheme.js"; -import { ED25519, createInitializeEd25519 } from "./schemes/ed25519.js"; -import { SECP256K1, createInitializeSecp256k1 } from "./schemes/secp256k1.js"; -import { EIP191, createInitializeEip191 } from "./schemes/eip191.js"; -import { - FALCON512, - createInitializeFalcon512, - Falcon512Keypair, -} from "./schemes/falcon512.js"; -import { - HAWK512, - createInitializeHawk512, - createHawk512StoreWire, - createHawk512Finalize, - Hawk512Keypair, -} from "./schemes/hawk512.js"; +import { Scheme, fetchVectorAccount } from "./scheme.js"; import { createPassthroughInstruction, createWithdrawSubinstruction, @@ -61,39 +29,14 @@ import { } from "./instructions.js"; import { ChainSigner, - ed25519ChainSigner, - secp256k1ChainSigner, - eip191ChainSigner, - falcon512ChainSigner, - hawk512ChainSigner, signChain, signBranches, resolveChainStatus, - deriveLaneSeed, BranchStep, SignedStep, ChainStatus, } from "./branching.js"; -const COMPUTE_BUDGET_PROGRAM_ID = new Address( - "ComputeBudget111111111111111111111111111111" -); - -/** `ComputeBudgetProgram.setComputeUnitLimit` ix, built by hand (disc 2 + u32 LE). */ -function setComputeUnitLimitIx(units: number): TransactionInstruction { - const data = new Uint8Array(5); - data[0] = 2; - data[1] = units & 0xff; - data[2] = (units >>> 8) & 0xff; - data[3] = (units >>> 16) & 0xff; - data[4] = (units >>> 24) & 0xff; - return new TransactionInstruction({ - programId: COMPUTE_BUDGET_PROGRAM_ID, - keys: [], - data: Buffer.from(data), - }); -} - export type { ChainStatus } from "./branching.js"; /** @@ -133,6 +76,24 @@ export interface Artifact { transaction(): Transaction; } +/** The pieces a per-scheme constructor assembles into a {@link Vector}. */ +export interface VectorParts { + scheme: Scheme; + signer: ChainSigner; + pda: Address; + /** Single-transaction registration instruction (single-tx schemes). */ + initIx?: (payer: Address) => TransactionInstruction; + /** Multi-transaction registration groups (e.g. Hawk-512). */ + registerGroups?: (payer: Address) => TransactionInstruction[][]; + /** Derive an independent sub-account (omit for schemes without seed derivation). */ + deriveChild?: (index: number) => Vector; + /** Verification pubkey when it differs from the identity (Falcon/Hawk wire). */ + publicKey?: Uint8Array; + /** Top-level instructions prepended to every advance (e.g. compute budget). */ + preIxs?: TransactionInstruction[]; + feePayer?: Address; +} + export class Vector { /** The signing scheme (program) this account uses. */ readonly scheme: Scheme; @@ -149,17 +110,7 @@ export class Vector { private readonly publicKey?: Uint8Array; private readonly preIxs: TransactionInstruction[]; - private constructor(args: { - scheme: Scheme; - signer: ChainSigner; - pda: Address; - initIx?: (payer: Address) => TransactionInstruction; - registerGroups?: (payer: Address) => TransactionInstruction[][]; - feePayer?: Address; - deriveChild?: (index: number) => Vector; - publicKey?: Uint8Array; - preIxs?: TransactionInstruction[]; - }) { + private constructor(args: VectorParts) { this.scheme = args.scheme; this.signer = args.signer; this.identity = args.signer.identity; @@ -172,94 +123,13 @@ export class Vector { this.preIxs = args.preIxs ?? []; } - /** Bind a `Vector` to a 32-byte Ed25519 private-key seed. */ - static ed25519(key: Uint8Array, opts?: { feePayer?: Address }): Vector { - const signer = ed25519ChainSigner(key); - const [pda] = findVectorPda(ED25519, signer.identity); - return new Vector({ - scheme: ED25519, - signer, - pda, - initIx: (payer) => createInitializeEd25519(payer, signer.identity), - feePayer: opts?.feePayer, - deriveChild: (i) => Vector.ed25519(deriveLaneSeed(key, "ed25519", i), opts), - }); - } - - /** Bind a `Vector` to a 32-byte plain secp256k1 (ECDSA) private key. */ - static secp256k1(privateKey: Uint8Array, opts?: { feePayer?: Address }): Vector { - const signer = secp256k1ChainSigner(privateKey); - const [pda] = findVectorPda(SECP256K1, signer.identity); - return new Vector({ - scheme: SECP256K1, - signer, - pda, - initIx: (payer) => createInitializeSecp256k1(payer, signer.identity), - feePayer: opts?.feePayer, - deriveChild: (i) => - Vector.secp256k1(deriveLaneSeed(privateKey, "secp256k1", i), opts), - }); - } - - /** Bind a `Vector` to a 32-byte secp256k1 key, signing as an Ethereum (EIP-191) address. */ - static eip191(privateKey: Uint8Array, opts?: { feePayer?: Address }): Vector { - const signer = eip191ChainSigner(privateKey); - const [pda] = findVectorPda(EIP191, signer.identity); - return new Vector({ - scheme: EIP191, - signer, - pda, - initIx: (payer) => createInitializeEip191(payer, signer.identity), - feePayer: opts?.feePayer, - deriveChild: (i) => - Vector.eip191(deriveLaneSeed(privateKey, "eip191", i), opts), - }); - } - /** - * Bind a `Vector` to a post-quantum Falcon-512 keypair. `derive` is - * unavailable (Falcon has no 32-byte seed) — construct each sub-account from - * its own keypair. + * Assemble a `Vector` from its parts. Used by the per-scheme constructors + * (`vectorEd25519`, `vectorSecp256k1`, …) in the scheme subpath modules — + * prefer those over calling this directly. */ - static falcon512( - keypair: Falcon512Keypair, - opts?: { feePayer?: Address } - ): Vector { - const signer = falcon512ChainSigner(keypair); - const [pda] = findVectorPda(FALCON512, signer.identity); - return new Vector({ - scheme: FALCON512, - signer, - pda, - initIx: (payer) => createInitializeFalcon512(payer, keypair.publicKey), - feePayer: opts?.feePayer, - publicKey: keypair.publicKey, - }); - } - - /** - * Bind a `Vector` to a post-quantum Hawk-512 keypair. Registration is a - * three-transaction flow (see {@link register}), so `initialize` throws — - * use `register`. `derive` is unavailable (build each sub-account from its - * own keypair). The advance carries a compute-budget bump (Hawk verification - * is heavy), committed to by the digest. - */ - static hawk512(keypair: Hawk512Keypair, opts?: { feePayer?: Address }): Vector { - const signer = hawk512ChainSigner(keypair); - const [pda] = findVectorPda(HAWK512, signer.identity); - return new Vector({ - scheme: HAWK512, - signer, - pda, - registerGroups: (payer) => [ - [createInitializeHawk512(payer, keypair.publicKey)], - [createHawk512StoreWire(keypair.publicKey)], - [setComputeUnitLimitIx(600_000), createHawk512Finalize(keypair.publicKey)], - ], - feePayer: opts?.feePayer, - publicKey: keypair.publicKey, - preIxs: [setComputeUnitLimitIx(450_000)], - }); + static fromParts(args: VectorParts): Vector { + return new Vector(args); } /** @@ -279,8 +149,8 @@ export class Vector { /** * All account-registration transactions, in order — one inner array per * transaction. Single-transaction schemes return one group of one - * instruction; Hawk-512 returns three (initialize → store wire → finalize). - * Send each group as its own transaction, in order. + * instruction; Hawk-512 returns three. Send each group as its own + * transaction, in order. */ register(payer: Address): TransactionInstruction[][] { if (this.registerGroups) return this.registerGroups(payer); @@ -304,8 +174,7 @@ export class Vector { /** * Authorize a SOL withdrawal from this account's PDA to `to`. Convenience - * for the program's own `withdraw` instruction — no need to reach for the - * low-level builder or supply the scheme/identity. + * for the program's own `withdraw` instruction. */ withdraw(nonce: Uint8Array, to: Address, lamports: bigint): Artifact { return this.authorize( @@ -324,7 +193,7 @@ export class Vector { /** * Authorize an ordered, forward-secure chain of ops starting at `nonce`. - * Op `i` is signed against the nonce op `i-1` produces, so the ops can only + * Op `i` is signed against the nonce op `i-1` produces, so they can only * execute in order. Broadcast each returned artifact's `transaction()`. */ chain(nonce: Uint8Array, ops: Op[]): Artifact[] { @@ -362,7 +231,9 @@ export class Vector { * Derive an independent sub-account (its own chain) from this account's key. * Use for non-exclusive parallel work (e.g. one position per RFQ deal). The * returned `Vector` has the same API and a distinct identity/PDA, and - * inherits this account's `feePayer`. + * inherits this account's `feePayer`. Unavailable for schemes without + * seed-based derivation (Falcon/Hawk) — build those sub-accounts from their + * own keypairs. */ derive(index: number): Vector { if (!this.deriveChild) { @@ -375,23 +246,20 @@ export class Vector { /** * Where a pre-signed chain stands given the current on-chain nonce: - * `pending` (and which op is next), `completed`, or `orphaned`. Assumes - * facade-shaped artifacts (`[advance, ...passthrough]`), which is everything - * the facade emits. + * `pending` (and which op is next), `completed`, or `orphaned`. */ status(artifacts: Artifact[], currentNonce: Uint8Array): ChainStatus { const steps: SignedStep[] = artifacts.map((a, index) => ({ index, nonce: a.nonce, nextNonce: a.nextNonce, - advanceIx: a.instructions[0], - pre: [], - post: a.instructions.slice(1), + advanceIx: a.instructions[a.advanceIndex], + pre: a.instructions.slice(0, a.advanceIndex), + post: a.instructions.slice(a.advanceIndex + 1), })); return resolveChainStatus(steps, currentNonce); } - /** Wrap an op's CPIs in a passthrough (or nothing, for an inert advance). */ private toStep(op: Op): BranchStep { const ixs = Array.isArray(op) ? op : [op]; const pre = this.preIxs.length ? this.preIxs : undefined; diff --git a/sdk/ts/test/branching.unit.test.ts b/sdk/ts/test/branching.unit.test.ts index f2e2251..d140d21 100644 --- a/sdk/ts/test/branching.unit.test.ts +++ b/sdk/ts/test/branching.unit.test.ts @@ -2,7 +2,6 @@ import { describe, test, expect } from "vitest"; import { Address, SystemProgram } from "@solana/web3.js"; import type { Connection } from "@solana/web3.js"; import { - ed25519ChainSigner, signChain, signBranches, resolveChainStatus, @@ -10,12 +9,11 @@ import { fetchChainStatus, } from "../src/branching.js"; import { - ED25519, - ed25519Identity, ADVANCE_DISCRIMINATOR, advanceVectorDigest, serializeVectorAccountHeader, } from "../src/index.js"; +import { ED25519, ed25519Identity, ed25519ChainSigner } from "../src/schemes/ed25519.js"; const KEY = new Uint8Array(32); KEY[31] = 0x07; diff --git a/sdk/ts/test/inspect.unit.test.ts b/sdk/ts/test/inspect.unit.test.ts index 4db39bd..5e26936 100644 --- a/sdk/ts/test/inspect.unit.test.ts +++ b/sdk/ts/test/inspect.unit.test.ts @@ -1,9 +1,6 @@ import { describe, test, expect } from "vitest"; import { Address, SystemProgram, Transaction } from "@solana/web3.js"; import { - Vector, - falcon512Keygen, - hawk512Keygen, serializeArtifact, deserializeArtifact, decodeOps, @@ -11,13 +8,18 @@ import { verifyArtifact, review, } from "../src/index.js"; +import { vectorEd25519 } from "../src/schemes/ed25519.js"; +import { vectorSecp256k1 } from "../src/schemes/secp256k1.js"; +import { vectorEip191 } from "../src/schemes/eip191.js"; +import { falcon512Keygen, vectorFalcon512 } from "../src/schemes/falcon512.js"; +import { hawk512Keygen, vectorHawk512 } from "../src/schemes/hawk512.js"; const KEY = new Uint8Array(32); KEY[31] = 0x11; const A = new Address("11111111111111111111111111111112"); const B = new Address("11111111111111111111111111111113"); const NONCE = new Uint8Array(32).fill(2); -const v = Vector.ed25519(KEY); // no feePayer → digest is feePayer-independent +const v = vectorEd25519(KEY); // no feePayer → digest is feePayer-independent const transfer = SystemProgram.transfer({ fromPubkey: A, toPubkey: B, lamports: 5 }); describe("serialize", () => { @@ -63,7 +65,7 @@ describe("verifyArtifact", () => { describe("verifyArtifact with a fee payer", () => { test("verifies an artifact whose digest binds the fee payer", () => { const feePayer = new Address("11111111111111111111111111111119"); - const vf = Vector.ed25519(KEY, { feePayer }); + const vf = vectorEd25519(KEY, { feePayer }); // op references the fee payer → exercises message-flag promotion in the digest const op = SystemProgram.transfer({ fromPubkey: feePayer, toPubkey: B, lamports: 9 }); const art = vf.authorize(NONCE, op); @@ -78,27 +80,27 @@ describe("verifyArtifact across schemes", () => { const op = SystemProgram.transfer({ fromPubkey: A, toPubkey: B, lamports: 5 }); test("ed25519", () => { - expect(verifyArtifact(Vector.ed25519(KEY).authorize(NONCE, op))).toBe(true); + expect(verifyArtifact(vectorEd25519(KEY).authorize(NONCE, op))).toBe(true); }); test("secp256k1", () => { - expect(verifyArtifact(Vector.secp256k1(secpKey).authorize(NONCE, op))).toBe(true); + expect(verifyArtifact(vectorSecp256k1(secpKey).authorize(NONCE, op))).toBe(true); }); test("eip191", () => { - expect(verifyArtifact(Vector.eip191(secpKey).authorize(NONCE, op))).toBe(true); + expect(verifyArtifact(vectorEip191(secpKey).authorize(NONCE, op))).toBe(true); }); test("falcon512 carries the wire pubkey and verifies", () => { - const art = Vector.falcon512(falcon512Keygen()).authorize(NONCE, op); + const art = vectorFalcon512(falcon512Keygen()).authorize(NONCE, op); expect(art.publicKey).toBeDefined(); expect(verifyArtifact(art)).toBe(true); }); test("hawk512 carries the wire pubkey and verifies (advance after compute budget)", () => { - const art = Vector.hawk512(hawk512Keygen()).authorize(NONCE, op); + const art = vectorHawk512(hawk512Keygen()).authorize(NONCE, op); expect(art.publicKey).toBeDefined(); expect(art.advanceIndex).toBe(1); // compute-budget pre-instruction expect(verifyArtifact(art)).toBe(true); }); test("tamper is rejected (secp256k1)", () => { - const art = Vector.secp256k1(secpKey).authorize(NONCE, op); + const art = vectorSecp256k1(secpKey).authorize(NONCE, op); const data = art.instructions[1].data; data[data.length - 1] ^= 0xff; expect(verifyArtifact(art)).toBe(false); diff --git a/sdk/ts/test/vector.test.ts b/sdk/ts/test/vector.test.ts index b892ede..404e0bd 100644 --- a/sdk/ts/test/vector.test.ts +++ b/sdk/ts/test/vector.test.ts @@ -1,6 +1,9 @@ import { describe, test, expect, beforeAll, afterAll } from "vitest"; import { Address, Connection, Keypair, SystemProgram, Transaction } from "@solana/web3.js"; -import { Vector, hawk512Keygen } from "../src/index.js"; +import { vectorEd25519 } from "../src/schemes/ed25519.js"; +import { vectorSecp256k1 } from "../src/schemes/secp256k1.js"; +import { vectorEip191 } from "../src/schemes/eip191.js"; +import { vectorHawk512, hawk512Keygen } from "../src/schemes/hawk512.js"; import { RPC_URL, WS_URL, FEE_PAYER_SEED, sendTx } from "./helpers.js"; // Distinct keys so this suite never collides with the per-scheme tests. @@ -25,7 +28,7 @@ describe("Vector (front door, on-chain)", () => { }); test("chain enforces order (forward-secrecy)", async () => { - const v = Vector.ed25519(CHAIN_KEY, { feePayer: feePayer.address }); + const v = vectorEd25519(CHAIN_KEY, { feePayer: feePayer.address }); await sendTx( connection, new Transaction().add(v.initialize(feePayer.address)), @@ -44,7 +47,7 @@ describe("Vector (front door, on-chain)", () => { }); test("branch is mutually exclusive (sign two, execute one)", async () => { - const v = Vector.ed25519(BRANCH_KEY, { feePayer: feePayer.address }); + const v = vectorEd25519(BRANCH_KEY, { feePayer: feePayer.address }); await sendTx( connection, new Transaction().add(v.initialize(feePayer.address)), @@ -65,7 +68,7 @@ describe("Vector (front door, on-chain)", () => { }); test("derived sub-accounts advance independently", async () => { - const root = Vector.ed25519(ROOT_KEY, { feePayer: feePayer.address }); + const root = vectorEd25519(ROOT_KEY, { feePayer: feePayer.address }); const a = root.derive(0); const b = root.derive(1); @@ -92,8 +95,8 @@ describe("Vector (front door, on-chain)", () => { // Non-ed25519 schemes verify on-chain through the same facade. const SCHEMES = [ - { name: "secp256k1", last: 0x61, make: (k: Uint8Array, o: { feePayer: Address }) => Vector.secp256k1(k, o) }, - { name: "eip191", last: 0x62, make: (k: Uint8Array, o: { feePayer: Address }) => Vector.eip191(k, o) }, + { name: "secp256k1", last: 0x61, make: (k: Uint8Array, o: { feePayer: Address }) => vectorSecp256k1(k, o) }, + { name: "eip191", last: 0x62, make: (k: Uint8Array, o: { feePayer: Address }) => vectorEip191(k, o) }, ]; for (const s of SCHEMES) { test(`${s.name}: facade init + authorize advances on-chain`, async () => { @@ -109,7 +112,7 @@ describe("Vector (front door, on-chain)", () => { } test("hawk512: 3-tx registration + advance on-chain", async () => { - const v = Vector.hawk512(hawk512Keygen(), { feePayer: feePayer.address }); + const v = vectorHawk512(hawk512Keygen(), { feePayer: feePayer.address }); // register() returns one group per transaction (initialize / storeWire / finalize) for (const group of v.register(feePayer.address)) { await sendTx(connection, new Transaction().add(...group), [feePayer]); diff --git a/sdk/ts/test/vector.unit.test.ts b/sdk/ts/test/vector.unit.test.ts index 41b1243..0493f9a 100644 --- a/sdk/ts/test/vector.unit.test.ts +++ b/sdk/ts/test/vector.unit.test.ts @@ -1,24 +1,26 @@ import { describe, test, expect } from "vitest"; import { Address, SystemProgram, Transaction } from "@solana/web3.js"; import { - Vector, - ED25519, - SECP256K1, - EIP191, - FALCON512, - HAWK512, - ed25519Identity, - secp256k1Identity, - eip191Identity, - falcon512Identity, - falcon512Keygen, - hawk512Identity, - hawk512Keygen, findVectorPda, ADVANCE_DISCRIMINATOR, PASSTHROUGH_DISCRIMINATOR, INITIALIZE_DISCRIMINATOR, } from "../src/index.js"; +import { ED25519, ed25519Identity, vectorEd25519 } from "../src/schemes/ed25519.js"; +import { SECP256K1, secp256k1Identity, vectorSecp256k1 } from "../src/schemes/secp256k1.js"; +import { EIP191, eip191Identity, vectorEip191 } from "../src/schemes/eip191.js"; +import { + FALCON512, + falcon512Identity, + falcon512Keygen, + vectorFalcon512, +} from "../src/schemes/falcon512.js"; +import { + HAWK512, + hawk512Identity, + hawk512Keygen, + vectorHawk512, +} from "../src/schemes/hawk512.js"; const KEY = new Uint8Array(32); KEY[31] = 0x11; @@ -29,14 +31,14 @@ const ix = (lamports: number) => describe("Vector construction", () => { test("binds identity + pda", () => { - const v = Vector.ed25519(KEY); + const v = vectorEd25519(KEY); expect(Buffer.from(v.identity)).toEqual(Buffer.from(ed25519Identity(KEY))); const [pda] = findVectorPda(ED25519, v.identity); expect(v.pda.toBase58()).toBe(pda.toBase58()); }); test("initialize targets the pda", () => { - const v = Vector.ed25519(KEY); + const v = vectorEd25519(KEY); const i = v.initialize(PAY); expect(i.data[0]).toBe(INITIALIZE_DISCRIMINATOR); expect(i.keys[1].pubkey.toBase58()).toBe(v.pda.toBase58()); @@ -44,7 +46,7 @@ describe("Vector construction", () => { }); describe("authorize", () => { - const v = Vector.ed25519(KEY, { feePayer: PAY }); + const v = vectorEd25519(KEY, { feePayer: PAY }); test("single op → [advance, passthrough] artifact", () => { const art = v.authorize(NONCE, ix(1)); @@ -69,7 +71,7 @@ describe("authorize", () => { }); describe("chain", () => { - const v = Vector.ed25519(KEY, { feePayer: PAY }); + const v = vectorEd25519(KEY, { feePayer: PAY }); test("ops are chained: each starts where the previous ended", () => { const arts = v.chain(NONCE, [ix(1), ix(2), []]); expect(arts.length).toBe(3); @@ -80,7 +82,7 @@ describe("chain", () => { }); describe("branch", () => { - const v = Vector.ed25519(KEY, { feePayer: PAY }); + const v = vectorEd25519(KEY, { feePayer: PAY }); test("alternatives share the parent nonce, diverge after", () => { const { settle, cancel } = v.branch(NONCE, { settle: ix(10), cancel: [] }); expect(Buffer.from(settle.nonce)).toEqual(Buffer.from(NONCE)); @@ -91,7 +93,7 @@ describe("branch", () => { describe("derive", () => { test("sub-accounts are deterministic and distinct", () => { - const v = Vector.ed25519(KEY); + const v = vectorEd25519(KEY); expect(v.derive(0).pda.toBase58()).toBe(v.derive(0).pda.toBase58()); expect(v.derive(0).pda.toBase58()).not.toBe(v.derive(1).pda.toBase58()); expect(v.derive(0).pda.toBase58()).not.toBe(v.pda.toBase58()); @@ -99,7 +101,7 @@ describe("derive", () => { }); describe("withdraw / close", () => { - const v = Vector.ed25519(KEY, { feePayer: PAY }); + const v = vectorEd25519(KEY, { feePayer: PAY }); const to = new Address("11111111111111111111111111111119"); test("withdraw builds a passthrough'd withdraw artifact", () => { @@ -121,7 +123,7 @@ describe("multi-scheme facade", () => { secpKey[31] = 7; // valid secp256k1 scalar test("secp256k1 binds compressed-pubkey identity + signs", () => { - const v = Vector.secp256k1(secpKey, { feePayer: PAY }); + const v = vectorSecp256k1(secpKey, { feePayer: PAY }); expect(v.scheme.programId.toBase58()).toBe(SECP256K1.programId.toBase58()); expect(Buffer.from(v.identity)).toEqual(Buffer.from(secp256k1Identity(secpKey))); expect(v.pda.toBase58()).toBe(findVectorPda(SECP256K1, v.identity)[0].toBase58()); @@ -131,7 +133,7 @@ describe("multi-scheme facade", () => { }); test("eip191 binds the 20-byte ETH address identity + signs (65-byte sig)", () => { - const v = Vector.eip191(secpKey, { feePayer: PAY }); + const v = vectorEip191(secpKey, { feePayer: PAY }); expect(v.scheme.programId.toBase58()).toBe(EIP191.programId.toBase58()); expect(v.identity.length).toBe(20); expect(Buffer.from(v.identity)).toEqual(Buffer.from(eip191Identity(secpKey))); @@ -141,7 +143,7 @@ describe("multi-scheme facade", () => { test("falcon512 binds sha256(wire) identity; derive throws", () => { const kp = falcon512Keygen(); - const v = Vector.falcon512(kp, { feePayer: PAY }); + const v = vectorFalcon512(kp, { feePayer: PAY }); expect(v.scheme.programId.toBase58()).toBe(FALCON512.programId.toBase58()); expect(Buffer.from(v.identity)).toEqual(Buffer.from(falcon512Identity(kp.publicKey))); const art = v.authorize(NONCE, ix(1)); @@ -150,14 +152,14 @@ describe("multi-scheme facade", () => { }); test("secp256k1 derive yields deterministic, distinct sub-accounts", () => { - const v = Vector.secp256k1(secpKey); + const v = vectorSecp256k1(secpKey); expect(v.derive(0).pda.toBase58()).toBe(v.derive(0).pda.toBase58()); expect(v.derive(0).pda.toBase58()).not.toBe(v.derive(1).pda.toBase58()); }); test("hawk512: 3-tx register(), compute-budget pre, no single initialize/derive", () => { const kp = hawk512Keygen(); - const v = Vector.hawk512(kp, { feePayer: PAY }); + const v = vectorHawk512(kp, { feePayer: PAY }); expect(v.scheme.programId.toBase58()).toBe(HAWK512.programId.toBase58()); expect(Buffer.from(v.identity)).toEqual(Buffer.from(hawk512Identity(kp.publicKey))); @@ -173,7 +175,7 @@ describe("multi-scheme facade", () => { }); describe("status", () => { - const v = Vector.ed25519(KEY, { feePayer: PAY }); + const v = vectorEd25519(KEY, { feePayer: PAY }); const arts = v.chain(NONCE, [ix(1), ix(2)]); test("pending / completed / orphaned", () => { expect(v.status(arts, arts[0].nonce)).toEqual({ state: "pending", nextStepIndex: 0 }); diff --git a/sdk/ts/test/wallet.test.ts b/sdk/ts/test/wallet.test.ts index d975cfc..a42e386 100644 --- a/sdk/ts/test/wallet.test.ts +++ b/sdk/ts/test/wallet.test.ts @@ -1,12 +1,11 @@ import { describe, test, expect, beforeAll, afterAll } from "vitest"; import { Connection, Keypair, Transaction } from "@solana/web3.js"; import { - Vector, - ED25519, createWithdrawSubinstruction, createFundWalletInstruction, scanMigration, } from "../src/index.js"; +import { ED25519, vectorEd25519 } from "../src/schemes/ed25519.js"; import { RPC_URL, WS_URL, FEE_PAYER_SEED, sendTx } from "./helpers.js"; const KEY = new Uint8Array(32); @@ -26,7 +25,7 @@ describe("fund-in-PDA wallet (on-chain)", () => { }); test("PDA holds SOL and spends it via an offline-signed artifact", async () => { - const v = Vector.ed25519(KEY, { feePayer: feePayer.address }); + const v = vectorEd25519(KEY, { feePayer: feePayer.address }); await sendTx( connection, new Transaction() @@ -48,7 +47,7 @@ describe("fund-in-PDA wallet (on-chain)", () => { }); test("scanMigration runs against real RPC and flags the old key's SOL", async () => { - const v = Vector.ed25519(KEY); + const v = vectorEd25519(KEY); // Scanning the funded fee payer exercises getBalance / getTokenAccountsByOwner // / getProgramAccounts against the live validator (shape validation). const report = await scanMigration(connection, { owner: feePayer.address, pda: v.pda }); From 649c0e2088fe81d017390d6761b19ff51a14b991 Mon Sep 17 00:00:00 2001 From: L0STE Date: Thu, 18 Jun 2026 22:16:44 +0200 Subject: [PATCH 14/14] chore(sdk): post-quantum libs as optional peer deps Move @noble/post-quantum and @blueshift-gg/hawk512 from dependencies to optional peerDependencies (with peerDependenciesMeta optional: true) and devDependencies. ed25519 / secp256k1 / eip191 users now install zero post-quantum code; falcon512 / hawk512 users add the one library their subpath needs. README documents the install model. --- package.json | 12 ++++++++++-- sdk/ts/README.md | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4244cbd..824eb78 100644 --- a/package.json +++ b/package.json @@ -73,13 +73,21 @@ "test": "bun vitest run" }, "dependencies": { - "@blueshift-gg/hawk512": "0.0.0", "@noble/curves": "^1.8.0", "@noble/hashes": "^1.7.0", - "@noble/post-quantum": "^0.6.1", "@solana/web3.js": "3.0.0-rc.0" }, + "peerDependencies": { + "@blueshift-gg/hawk512": "0.0.0", + "@noble/post-quantum": "^0.6.1" + }, + "peerDependenciesMeta": { + "@blueshift-gg/hawk512": { "optional": true }, + "@noble/post-quantum": { "optional": true } + }, "devDependencies": { + "@blueshift-gg/hawk512": "0.0.0", + "@noble/post-quantum": "^0.6.1", "@solana/spl-token": "^0.4.0", "typescript": "^5.4.0", "vitest": "^3.1.0" diff --git a/sdk/ts/README.md b/sdk/ts/README.md index 4183701..c18ee4b 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -31,6 +31,11 @@ any Ethereum `personal_sign` wallet. Core (`import … from "vector-sdk"`) gives you the `Vector` type, inspection, migration, and the scanner; the low-level chain engine is at `vector-sdk/branching`. +The post-quantum libraries are **optional peer dependencies** — a base install +doesn't pull them. Add `@noble/post-quantum` only if you use `vector-sdk/falcon512`, +and `@blueshift-gg/hawk512` only for `vector-sdk/hawk512`. The other schemes need +nothing beyond `@noble/curves`. + ## Quickstart ```ts