diff --git a/package.json b/package.json index e4752c0..824eb78 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,30 @@ "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" + }, + "./inspect": { + "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" + }, "./ed25519": { "import": "./sdk/ts/dist/schemes/ed25519.js", "types": "./sdk/ts/dist/schemes/ed25519.d.ts" @@ -49,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 new file mode 100644 index 0000000..c18ee4b --- /dev/null +++ b/sdk/ts/README.md @@ -0,0 +1,179 @@ +# 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 +``` + +## Import model + +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`. + +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 +import { vectorEd25519 } from "vector-sdk/ed25519"; +import { Connection, sendAndConfirmTransaction } from "@solana/web3.js"; + +const connection = new Connection("https://api.devnet.solana.com"); +const v = vectorEd25519(signingKey, { feePayer: relayer.address }); + +v.identity; // 32-byte pubkey +v.pda; // the account's on-chain address + +// one-time setup +await sendAndConfirmTransaction( + connection, + new Transaction().add(v.initialize(payer.address)), + [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: `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 + +```ts +// SEQUENCE — ordered, forward-secure (each op only executes after the prior) +const steps = v.chain(nonce, [opA, opB, opC]); + +// ALTERNATIVES — sign N, execute one; the rest orphan atomically +const { settle, cancel } = v.branch(nonce, { settle: paymentIx, cancel: [] }); // [] = inert + +// PARALLEL — independent sub-accounts (one per RFQ deal), same API, own chain +const pos0 = v.derive(0); +``` + +`derive` works for the 32-byte-key schemes (ed25519 / secp256k1 / eip191); +post-quantum sub-accounts are built from their own keypairs. + +## 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] +); +``` + +## 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**. `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 "vector-sdk/ed25519"; // registers the Ed25519 verifier + +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 +``` + +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) + +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, scanMigration, +} from "vector-sdk"; + +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)); // spend tokens + +const report = await scanMigration(connection, { + owner: oldKey, // the keypair you're migrating away from + 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 +``` + +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 +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 +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, bespoke digest handling). +``` diff --git a/sdk/ts/src/branching.ts b/sdk/ts/src/branching.ts new file mode 100644 index 0000000..bcb5ba3 --- /dev/null +++ b/sdk/ts/src/branching.ts @@ -0,0 +1,228 @@ +/** + * 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 { createHmac } from "crypto"; + +import { Scheme, fetchVectorAccount } from "./scheme.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; +} + +/** 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" }; + } +} + +// ── 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"); + +/** 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). + * 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 hkdfSha256(masterSeed, LANE_KDF_SALT, info, 32); +} diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index e20fdf1..43b108b 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -1,71 +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 "./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"; +export * from "./vector.js"; +export * from "./inspect.js"; +export * from "./wallet.js"; +export * from "./migrate.js"; +export * from "./scan.js"; diff --git a/sdk/ts/src/inspect.ts b/sdk/ts/src/inspect.ts new file mode 100644 index 0000000..97f4c1b --- /dev/null +++ b/sdk/ts/src/inspect.ts @@ -0,0 +1,244 @@ +/** + * Inspect, serialize, and offline-verify Vector {@link Artifact}s — so policy + * 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 { + Scheme, + readU16LE, + ADVANCE_DISCRIMINATOR, + CLOSE_DISCRIMINATOR, + WITHDRAW_DISCRIMINATOR, + PASSTHROUGH_DISCRIMINATOR, +} from "./scheme.js"; +import { advanceVectorDigest } from "./digest.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"; + +// ── 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, + publicKey: a.publicKey ? toHex(a.publicKey) : undefined, + advanceIndex: a.advanceIndex, + 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, + publicKey: o.publicKey ? fromHex(o.publicKey) : undefined, + advanceIndex: o.advanceIndex ?? 0, + 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(); + 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 && ix.accounts.length >= 2) { + return `Vector close → ${short(ix.accounts[1].pubkey)}`; + } + 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 && 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 raw; + }); +} + +/** 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"); +} + +// ── Offline verification (registry) ────────────────────────────────── + +/** Recompute-and-check verifier for one scheme's artifacts. */ +export type ArtifactVerifier = (artifact: Artifact) => boolean; + +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); +} + +/** + * 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 verify = verifiers.get(a.programId.toBase58()); + if (!verify) { + throw new Error( + `verifyArtifact: no verifier registered for ${a.programId.toBase58()} — import its scheme module (e.g. "vector-sdk/ed25519")` + ); + } + return verify(a); +} + +/** + * 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, a.advanceIndex), + a.instructions.slice(a.advanceIndex + 1), + a.feePayer + ); + return { signature, digest }; +} 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/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 new file mode 100644 index 0000000..4981ca2 --- /dev/null +++ b/sdk/ts/src/vector.ts @@ -0,0 +1,287 @@ +/** + * `Vector` — the scheme-agnostic core of the SDK. + * + * 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. + * + * 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, + Connection, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; + +import { Scheme, fetchVectorAccount } from "./scheme.js"; +import { + createPassthroughInstruction, + createWithdrawSubinstruction, + createCloseSubinstruction, +} from "./instructions.js"; +import { + ChainSigner, + signChain, + signBranches, + resolveChainStatus, + 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 { + /** 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; + /** + * 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; + /** + * 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; +} + +/** 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; + /** 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 signer: ChainSigner; + private readonly feePayer?: Address; + 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: VectorParts) { + 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 ?? []; + } + + /** + * 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 fromParts(args: VectorParts): Vector { + return new Vector(args); + } + + /** + * 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. 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); + 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 a SOL withdrawal from this account's PDA to `to`. Convenience + * for the program's own `withdraw` instruction. + */ + 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 they 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. 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); + 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, and + * 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) { + 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); + } + + /** + * 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[a.advanceIndex], + pre: a.instructions.slice(0, a.advanceIndex), + post: a.instructions.slice(a.advanceIndex + 1), + })); + return resolveChainStatus(steps, currentNonce); + } + + private toStep(op: Op): BranchStep { + const ixs = Array.isArray(op) ? op : [op]; + 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.pre, step.advanceIx, ...step.post]; + return { + programId: this.scheme.programId, + identity: this.identity, + nonce: step.nonce, + nextNonce: step.nextNonce, + feePayer: this.feePayer, + publicKey: this.publicKey, + advanceIndex: step.pre.length, + instructions, + transaction: () => new Transaction().add(...instructions), + }; + } +} 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/branching.unit.test.ts b/sdk/ts/test/branching.unit.test.ts new file mode 100644 index 0000000..d140d21 --- /dev/null +++ b/sdk/ts/test/branching.unit.test.ts @@ -0,0 +1,140 @@ +import { describe, test, expect } from "vitest"; +import { Address, SystemProgram } from "@solana/web3.js"; +import type { Connection } from "@solana/web3.js"; +import { + signChain, + signBranches, + resolveChainStatus, + whichBranchWon, + fetchChainStatus, +} from "../src/branching.js"; +import { + 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; +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 }); + }); +}); diff --git a/sdk/ts/test/inspect.unit.test.ts b/sdk/ts/test/inspect.unit.test.ts new file mode 100644 index 0000000..5e26936 --- /dev/null +++ b/sdk/ts/test/inspect.unit.test.ts @@ -0,0 +1,133 @@ +import { describe, test, expect } from "vitest"; +import { Address, SystemProgram, Transaction } from "@solana/web3.js"; +import { + serializeArtifact, + deserializeArtifact, + decodeOps, + summarize, + 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 = vectorEd25519(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("verifyArtifact with a fee payer", () => { + test("verifies an artifact whose digest binds the fee payer", () => { + const feePayer = new Address("11111111111111111111111111111119"); + 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); + expect(art.feePayer?.toBase58()).toBe(feePayer.toBase58()); + expect(verifyArtifact(art)).toBe(true); + }); +}); + +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(vectorEd25519(KEY).authorize(NONCE, op))).toBe(true); + }); + test("secp256k1", () => { + expect(verifyArtifact(vectorSecp256k1(secpKey).authorize(NONCE, op))).toBe(true); + }); + test("eip191", () => { + expect(verifyArtifact(vectorEip191(secpKey).authorize(NONCE, op))).toBe(true); + }); + test("falcon512 carries the wire pubkey and verifies", () => { + 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 = 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 = vectorSecp256k1(secpKey).authorize(NONCE, op); + const data = art.instructions[1].data; + data[data.length - 1] ^= 0xff; + expect(verifyArtifact(art)).toBe(false); + }); +}); + +describe("verifyArtifact unknown scheme", () => { + test("throws for an unknown program id", () => { + const fake = { + 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; + expect(() => verifyArtifact(fake)).toThrow(); + }); +}); + +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); + }); +}); 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/vector.test.ts b/sdk/ts/test/vector.test.ts new file mode 100644 index 0000000..404e0bd --- /dev/null +++ b/sdk/ts/test/vector.test.ts @@ -0,0 +1,125 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { Address, Connection, Keypair, SystemProgram, Transaction } from "@solana/web3.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. +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 = vectorEd25519(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 = vectorEd25519(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 = vectorEd25519(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 + }); + + // Non-ed25519 schemes verify on-chain through the same facade. + const SCHEMES = [ + { 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 () => { + 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)); + }); + } + + test("hawk512: 3-tx registration + advance on-chain", async () => { + 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]); + } + 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 new file mode 100644 index 0000000..0493f9a --- /dev/null +++ b/sdk/ts/test/vector.unit.test.ts @@ -0,0 +1,185 @@ +import { describe, test, expect } from "vitest"; +import { Address, SystemProgram, Transaction } from "@solana/web3.js"; +import { + 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; +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 = 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 = vectorEd25519(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 = vectorEd25519(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 = 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); + 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 = 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)); + 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 = 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()); + }); +}); + +describe("withdraw / close", () => { + const v = vectorEd25519(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("multi-scheme facade", () => { + const secpKey = new Uint8Array(32); + secpKey[31] = 7; // valid secp256k1 scalar + + test("secp256k1 binds compressed-pubkey identity + signs", () => { + 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()); + 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 = 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))); + 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 = 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)); + 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 = 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 = vectorHawk512(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", () => { + 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 }); + expect(v.status(arts, arts[1].nextNonce)).toEqual({ state: "completed" }); + expect(v.status(arts, new Uint8Array(32).fill(0xff))).toEqual({ state: "orphaned" }); + }); +}); diff --git a/sdk/ts/test/wallet.test.ts b/sdk/ts/test/wallet.test.ts new file mode 100644 index 0000000..a42e386 --- /dev/null +++ b/sdk/ts/test/wallet.test.ts @@ -0,0 +1,57 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { Connection, Keypair, Transaction } from "@solana/web3.js"; +import { + 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); +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 = vectorEd25519(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 = 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 }); + expect(report.complete).toBe(false); + expect(report.items.find((i) => i.kind === "sol")?.status).toBe("unmigrated"); + }); +});