From 0920c592befbf167ff21f1b5ff5d8378560801c4 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 25 Jun 2026 14:43:29 +0700 Subject: [PATCH 1/8] docs(sdk): spec for vault privacy provider reference example A public, naming-clean reference showing how to back a pluggable VaultPrivacyProvider interface with the sipher_vault native-SOL builders (deposit/withdraw_private/refund), including the full stealth + Pedersen commitment + viewing-key assembly. Design only; implementation follows. --- ...5-vault-privacy-provider-example-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md diff --git a/docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md b/docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md new file mode 100644 index 0000000..8afef44 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md @@ -0,0 +1,214 @@ +# Vault Privacy Provider — reference example (design) + +**Date:** 2026-06-25 +**Status:** Approved (design) +**Scope:** A public, reference example showing how to back a pluggable +"privacy provider" interface with the `sipher_vault` native-SOL operations, +consuming `@sipher/sdk` (+ `@sip-protocol/sdk` for stealth/commitment). + +--- + +## 1. Motivation + +SIP positions the vault as a **pluggable privacy backend** — an application keeps +its own abstraction for "make this transfer private" and swaps the implementation +underneath. Today there is no end-to-end reference that shows an integrator how to +satisfy such an abstraction with the vault's native-SOL instructions. + +This example fills that gap. It is deliberately shaped as a small, neutral +`VaultPrivacyProvider` interface so that an integrator who already has a +privacy-provider abstraction can adopt the vault with a thin mapping layer rather +than rewriting their pipeline. It also serves as living documentation for the +native-SOL builders shipped in `@sipher/sdk` (`buildDepositSolTx`, +`buildPrivateSendSolTx`, `buildRefundSolTx`, …). + +## 2. Goals / Non-goals + +**Goals** +- A typed `VaultPrivacyProvider` interface + a `SipherVaultPrivacyProvider` + implementation backed entirely by `@sipher/sdk` native-SOL builders. +- Demonstrate the **full private-withdraw assembly** (one-time stealth address + + Pedersen commitment + viewing-key encryption) — the part integrators most need + shown, because the SDK withdraw builder is intentionally low-level. +- Honest, in-code documentation of the **privacy model** (what it does and does + not hide). +- Unit tests (mocked RPC, real crypto) asserting each method builds a well-formed + instruction. + +**Non-goals** +- No new SDK surface — the example only *consumes* existing exports. +- No devnet/mainnet execution — the program is already deployed and the builders + are already SDK-tested; unit tests prove the mapping. +- Native SOL only (see §8). SPL / Token-2022 are a documented one-paragraph + extension, not built. + +## 3. The interface + +```ts +export interface VaultPrivacyProvider { + /** Protocol fee charged on a private withdrawal, in basis points. */ + readonly feeBps: number + + /** + * Build the unsigned funding transfer: the user's main wallet sends lamports + * to the shared vault-depositor wallet. This is a plain SystemProgram transfer + * and is NOT vault-specific — the vault takes over once funds land in the + * depositor wallet. + */ + buildFundingTx(args: { + fromPk: string + depositorPk: string + amountLamports: bigint + recentBlockhash: string + }): Promise + + /** Confirm the funding transfer landed and credited the expected lamports. */ + verifyFunding(args: { + depositorPk: string + expectedLamports: bigint + txSignature: string + }): Promise + + /** Deposit lamports from the shared depositor wallet into the vault. */ + deposit(args: { + depositorKp: Keypair + lamports: bigint + }): Promise<{ txSignature: string; depositedLamports: bigint }> + + /** + * Withdraw lamports from the vault to a one-time stealth recipient derived from + * `recipient` (a stealth meta-address), with a Pedersen commitment + viewing-key + * disclosure. The shared depositor signs. + */ + privateWithdraw(args: { + depositorKp: Keypair + recipient: StealthMetaAddress + lamports: bigint + }): Promise<{ txSignature: string; withdrawnLamports: bigint; feeLamports: bigint }> + + /** Self-recover an un-withdrawn balance back to the depositor wallet. */ + refund(args: { + depositorKp: Keypair + }): Promise<{ txSignature: string; refundedLamports: bigint }> + + /** Preview the fee + net for a given gross withdrawal amount. */ + previewWithdraw(grossLamports: bigint): { feeLamports: bigint; netLamports: bigint } +} +``` + +`StealthMetaAddress` carries the recipient's spending + viewing public keys (the +input to one-time stealth derivation). + +## 4. Mapping to the SDK + +| Method | Backed by (`@sipher/sdk`) | +|---|---| +| `feeBps` | read from the vault config (`getVaultConfig`), default 10 bps | +| `buildFundingTx` | `SystemProgram.transfer` (no SDK call) | +| `verifyFunding` | `connection.getTransaction` + lamport-delta check | +| `deposit` | `buildDepositSolTx(conn, depositorKp.publicKey, lamports)` → sign → send | +| `privateWithdraw` | assemble stealth artifacts → `buildPrivateSendSolTx({ … })` → sign → send | +| `refund` | `buildRefundSolTx(conn, depositorKp.publicKey)` → sign → send | +| `previewWithdraw` | `fee = gross * feeBps / 10_000`, `net = gross - fee` | + +All builders return an unsigned `Transaction` (with blockhash + fee payer set); the +provider signs with `depositorKp` and submits. The depositor is the fee payer and +the only required signer on deposit/withdraw/refund. + +## 5. Depositor-as-vault model (load-bearing) + +The vault's `withdraw_private_sol` requires the **depositor** as signer +(`DepositRecord` is keyed by depositor; the withdrawal debits that record). For an +integrator the depositor **must be a single shared aggregating wallet**, reused +across many users' flows. The example demonstrates exactly this: one shared +`depositorKp` signs every deposit and every withdrawal, so on-chain the graph is +`shared-depositor → stealth_N`, and the user↔recipient mapping lives off-chain in +the integrator's own records. + +**This is the non-negotiable usage rule** and the README states it plainly: a +per-user depositor would paint each user's `deposit → withdraw` link on-chain and +destroy the anonymity property. Unlinkability here comes from **commingling** (many +users sharing the depositor) **+ batching/jitter**, not from cryptography. + +### Honesty caveats (surfaced in code + README) +- **Not a cryptographic graph-break.** The depositor signature links the shared + depositor to each stealth payout on-chain. The crowd is the set of concurrent + users behind the shared depositor — not a zero-knowledge nullifier set. +- **Amounts are not hidden.** The Pedersen commitment is recorded for + disclosure/audit, but the lamport delta is visible on-chain (TIER_1 in the SDK's + privacy-tier model). Amount-hiding is a future tier. +- A short pointer to the SDK's `assessFlowPrivacy` (flow-privacy score) and + `PrivacyTier` fee model so consumers can compute an honest score for a flow. + +## 6. The private-withdraw assembly (the centerpiece) + +`buildPrivateSendSolTx` is low-level: it takes a pre-computed `stealthPubkey`, +`amountCommitment` (33B), `ephemeralPubkey` (33B), `viewingKeyHash` (32B), +`encryptedAmount`, and `proof`. The example shows the full assembly using +`@sip-protocol/sdk`: + +1. Derive a **one-time stealth address** + ephemeral key from the recipient's + stealth meta-address. +2. Compute the **Pedersen commitment** `C = amount·G + blinding·H` (33B compressed). +3. Compute the **viewing-key hash** and **encrypt the amount** under the viewing key. +4. Call `buildPrivateSendSolTx({ depositor, stealthPubkey, amount, amountCommitment, + ephemeralPubkey, viewingKeyHash, encryptedAmount, proof })`. + +This mirrors the **proven** assembly already used by the agent's private-send tool +(`packages/agent/src/tools/send.ts`) — `commit(amount)` for the Pedersen +commitment + blinding, `generateEd25519StealthAddress` for the stealth + ephemeral +key (ed25519 32B padded to 33B with a `0x00` prefix), `sha256(viewingKey)` for the +hash, and XChaCha20-Poly1305 over `[amount LE || blinding]` for `encryptedAmount`. +The native-SOL example drops the token-account derivation (a plain system account +receives lamports directly); the assembly is otherwise identical. + +It also surfaces the builder's **rent-exempt guard**: the stealth recipient is a +plain system account, so a small payout to a never-funded stealth is rejected by +the runtime. The example documents pre-funding the stealth (or relying on the +relayer/funding leg to do so) and lets the builder's actionable error propagate. + +> Note (out of scope, follow-on): this assembly is currently inline in the agent +> tool and would be duplicated here. Extracting it into a shared `@sipher/sdk` +> helper would DRY both call sites and give integrators a single high-level entry +> point — deferred to keep this example to "consume existing exports only". + +## 7. Error / fee handling +- All builder throws (zero amount, no deposit record, rent-floor violation) + propagate with their actionable messages — the example does not swallow them. +- `feeBps` reflects the **withdraw-only** fee semantics: `deposit` and `refund` + move the full amount; only `privateWithdraw` deducts the fee. `previewWithdraw` + mirrors the on-chain computation. + +## 8. Scope / YAGNI +- **Native SOL only.** The vault also supports classic SPL + Token-2022, but the + example stays SOL-focused for clarity. A closing README paragraph notes the SPL + path (`buildDepositTx` / `buildPrivateSendTx` with a `mint` + token program) as + the analogous extension. + +## 9. Testing strategy +- **Vitest unit tests**, mocked `Connection` (RPC reads stubbed: + `getLatestBlockhash`, `getAccountInfo`, `getMinimumBalanceForRentExemption`, + `getTransaction`), **real crypto** (deterministic from seeded inputs). +- Per method, assert the built instruction: program ID, account metas + (signer/writable flags in the documented order), instruction discriminator, and + data layout (e.g. commitment length 33, the shared depositor present and signing + on every fund-moving call). +- A focused test for the **depositor-as-vault invariant**: two distinct flows reuse + the same depositor key. +- A focused test for the **rent-exempt guard** path (small payout to an unfunded + stealth throws). + +## 10. File layout (indicative) +``` +examples/vault-privacy-provider/ + README.md # generic explanation + honesty caveats + SPL extension note + package.json # depends on @sipher/sdk (workspace:*) + @sip-protocol/sdk + src/ + provider.ts # VaultPrivacyProvider interface + SipherVaultPrivacyProvider + stealth.ts # the withdraw-assembly helper (stealth + commitment + encrypt) + types.ts # StealthMetaAddress, result types + test/ + provider.test.ts +``` +Exact workspace wiring (pnpm-workspace.yaml entry, tsconfig) is an implementation +detail for the plan. From 1a186ccf4bbc0bc847e3f93ca1f665247a489d4a Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 25 Jun 2026 14:55:24 +0700 Subject: [PATCH 2/8] docs(example): implementation plan for vault privacy provider example --- ...26-06-25-vault-privacy-provider-example.md | 866 ++++++++++++++++++ 1 file changed, 866 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-vault-privacy-provider-example.md diff --git a/docs/superpowers/plans/2026-06-25-vault-privacy-provider-example.md b/docs/superpowers/plans/2026-06-25-vault-privacy-provider-example.md new file mode 100644 index 0000000..0d69aa8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-vault-privacy-provider-example.md @@ -0,0 +1,866 @@ +# Vault Privacy Provider example — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a public, naming-clean reference example showing how to back a pluggable `VaultPrivacyProvider` interface with the `sipher_vault` native-SOL builders, including the full stealth + Pedersen-commitment + viewing-key withdraw assembly, with vitest unit tests. + +**Architecture:** A new workspace package `examples/vault-privacy-provider/` that *consumes* the merged `@sipher/sdk` native-SOL builders (`buildDepositSolTx`, `buildPrivateSendSolTx`, `buildRefundSolTx`) and `@sip-protocol/sdk` stealth/commitment primitives. A `SipherVaultPrivacyProvider` class implements the interface; a single shared depositor keypair signs every fund-moving call (depositor-as-vault commingling). Unit tests mock the RPC `Connection` (reusing the `packages/sdk/tests/privacy-sol.test.ts` pattern) and use real crypto. + +**Tech Stack:** TypeScript (ES2022, `moduleResolution: bundler`, `type: module`, `.js` import extensions), vitest 4, `@sipher/sdk` (workspace), `@sip-protocol/sdk`, `@noble/hashes`, `@noble/ciphers`, `@solana/web3.js`. + +## Global Constraints + +- **Naming gate (public repo):** no partner, competitor, or third-party product names or handles anywhere in code/comments/README. Run the deny-list grep supplied with the execution notes (kept out of this public file so the rule does not itself name the parties); it must return empty. +- **No new SDK surface.** The example only *consumes* existing `@sipher/sdk` / `@sip-protocol/sdk` exports. Do not add/modify SDK files. +- **Native SOL only.** Use the `*Sol*` builders + `NATIVE_SOL_MINT`. SPL/Token-2022 is a one-paragraph README note, not built. +- **Depositor-as-vault.** Every deposit/withdraw/refund is signed by ONE shared depositor keypair. A per-user depositor is forbidden (breaks commingling) — assert this with a dedicated test. +- **Honesty.** Code comments + README state: commingling/decorrelation, NOT cryptographic graph-break; amounts are visible (TIER_1, the Pedersen commitment is for disclosure/audit only). No "cryptographically hidden" / "even the operator can't link" copy. +- **Commits:** GPG-signed (key already configured), Conventional Commits, **no AI attribution** of any kind. +- **Build before test:** `@sipher/sdk` resolves to its built `dist/` (its `main`), so run `pnpm --filter @sipher/sdk build` once during setup before the example's tests can import it. + +--- + +## File Structure + +``` +examples/vault-privacy-provider/ + package.json # @sipher/example-vault-privacy-provider, private, scripts: test/typecheck + tsconfig.json # mirrors packages/sdk/tsconfig.json + README.md # generic explanation + honesty caveats + SPL extension note + src/ + hex.ts # hexToBytes (0x-aware) + bigintToLeBytes (local helpers) + types.ts # StealthMetaAddress, WithdrawArtifacts, VaultPrivacyProvider, result types + stealth.ts # parseStealthMetaAddress + assembleWithdrawArtifacts (the assembly centerpiece) + provider.ts # SipherVaultPrivacyProvider implements VaultPrivacyProvider + index.ts # barrel + test/ + hex.test.ts + stealth.test.ts + provider.test.ts +``` +Modify: `pnpm-workspace.yaml` (add `'examples/*'`). + +--- + +## Task 1: Scaffold the package + hex/bigint helpers + +**Files:** +- Modify: `pnpm-workspace.yaml` +- Create: `examples/vault-privacy-provider/package.json` +- Create: `examples/vault-privacy-provider/tsconfig.json` +- Create: `examples/vault-privacy-provider/src/hex.ts` +- Test: `examples/vault-privacy-provider/test/hex.test.ts` + +**Interfaces:** +- Produces: `hexToBytes(hex: string): Uint8Array`, `bigintToLeBytes(value: bigint, size?: number): Uint8Array` (from `src/hex.ts`). + +- [ ] **Step 1: Add the examples glob to the workspace** + +Edit `pnpm-workspace.yaml` — add `'examples/*'` to the `packages:` list: +```yaml +packages: + - '.' + - 'packages/*' + - 'app' + - 'examples/*' + - '!sdks/**' +``` + +- [ ] **Step 2: Create `examples/vault-privacy-provider/package.json`** + +```json +{ + "name": "@sipher/example-vault-privacy-provider", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Reference: back a pluggable privacy-provider interface with the sipher vault (native SOL).", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sipher/sdk": "workspace:*", + "@sip-protocol/sdk": "^0.11.0", + "@solana/web3.js": "^1.98.0", + "@noble/hashes": "^2.0.0", + "@noble/ciphers": "^2.2.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^4.1.9" + } +} +``` + +- [ ] **Step 3: Create `examples/vault-privacy-provider/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["src", "test"] +} +``` + +- [ ] **Step 4: Install + build the SDK so the workspace link + dist exist** + +Run from repo root: +```bash +pnpm install +pnpm --filter @sipher/sdk build +``` +Expected: install succeeds (new package linked); `packages/sdk/dist/index.js` exists. + +- [ ] **Step 5: Write the failing test** — `test/hex.test.ts` + +```ts +import { describe, it, expect } from 'vitest' +import { hexToBytes, bigintToLeBytes } from '../src/hex.js' + +describe('hexToBytes', () => { + it('parses a 0x-prefixed hex string', () => { + expect(Array.from(hexToBytes('0xff00ab'))).toEqual([255, 0, 171]) + }) + it('parses a bare (no 0x) hex string', () => { + expect(Array.from(hexToBytes('ff00ab'))).toEqual([255, 0, 171]) + }) + it('throws on odd-length hex', () => { + expect(() => hexToBytes('0xabc')).toThrow('Invalid hex length') + }) +}) + +describe('bigintToLeBytes', () => { + it('encodes a bigint little-endian in 8 bytes by default', () => { + expect(Array.from(bigintToLeBytes(2_000_000n))).toEqual([128, 132, 30, 0, 0, 0, 0, 0]) + }) + it('honours an explicit size', () => { + expect(Array.from(bigintToLeBytes(1n, 4))).toEqual([1, 0, 0, 0]) + }) +}) +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `pnpm --filter @sipher/example-vault-privacy-provider test` +Expected: FAIL — cannot resolve `../src/hex.js`. + +- [ ] **Step 7: Implement `src/hex.ts`** + +```ts +/** Convert an optionally 0x-prefixed hex string to bytes. */ +export function hexToBytes(hex: string): Uint8Array { + const h = hex.startsWith('0x') ? hex.slice(2) : hex + if (h.length % 2 !== 0) throw new Error(`Invalid hex length: ${hex}`) + const bytes = new Uint8Array(h.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +/** Convert a bigint to a little-endian byte array of `size` bytes. */ +export function bigintToLeBytes(value: bigint, size = 8): Uint8Array { + const buf = new Uint8Array(size) + let v = value + for (let i = 0; i < size; i++) { + buf[i] = Number(v & 0xffn) + v >>= 8n + } + return buf +} +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `pnpm --filter @sipher/example-vault-privacy-provider test` +Expected: PASS (5 tests). + +- [ ] **Step 9: Commit** + +```bash +git add pnpm-workspace.yaml examples/vault-privacy-provider/package.json examples/vault-privacy-provider/tsconfig.json examples/vault-privacy-provider/src/hex.ts examples/vault-privacy-provider/test/hex.test.ts pnpm-lock.yaml +git commit -m "feat(example): scaffold vault-privacy-provider package + hex helpers" +``` + +--- + +## Task 2: Stealth meta-address parse + withdraw-artifact assembly + +**Files:** +- Create: `examples/vault-privacy-provider/src/types.ts` +- Create: `examples/vault-privacy-provider/src/stealth.ts` +- Test: `examples/vault-privacy-provider/test/stealth.test.ts` + +**Interfaces:** +- Consumes: `hexToBytes`, `bigintToLeBytes` (Task 1); `generateEd25519StealthAddress`, `ed25519PublicKeyToSolanaAddress`, `commit` from `@sip-protocol/sdk`; `sha256` from `@noble/hashes/sha2.js`; `xchacha20poly1305` from `@noble/ciphers/chacha.js`; `randomBytes` from `node:crypto`. +- Produces (from `src/types.ts`): `interface StealthMetaAddress { spendingKey: \`0x${string}\`; viewingKey: \`0x${string}\`; chain: 'solana' }`; `interface WithdrawArtifacts { stealthPubkey: PublicKey; amountCommitment: Uint8Array; ephemeralPubkey: Uint8Array; viewingKeyHash: Uint8Array; encryptedAmount: Uint8Array; proof: Uint8Array }`. +- Produces (from `src/stealth.ts`): `parseStealthMetaAddress(uri: string): StealthMetaAddress`; `assembleWithdrawArtifacts(recipient: StealthMetaAddress, amountLamports: bigint): WithdrawArtifacts`. + +- [ ] **Step 1: Create `src/types.ts` (types only for now)** + +```ts +import type { PublicKey } from '@solana/web3.js' + +/** A recipient's stealth meta-address: spending + viewing public keys (0x-hex). */ +export interface StealthMetaAddress { + spendingKey: `0x${string}` + viewingKey: `0x${string}` + chain: 'solana' +} + +/** On-chain crypto artifacts a native-SOL private withdrawal requires. */ +export interface WithdrawArtifacts { + /** One-time stealth recipient (a plain SystemAccount). */ + stealthPubkey: PublicKey + /** Pedersen commitment C = amount*G + blinding*H (33 bytes). */ + amountCommitment: Uint8Array + /** Ephemeral pubkey for ECDH, 33 bytes (ed25519 padded with a 0x00 prefix). */ + ephemeralPubkey: Uint8Array + /** SHA-256 of the viewing key (32 bytes). */ + viewingKeyHash: Uint8Array + /** AEAD blob: [24-byte nonce] || [ciphertext+tag] over [amount LE(8) || blinding(32)]. */ + encryptedAmount: Uint8Array + /** ZK proof (empty — verified off-chain). */ + proof: Uint8Array +} +``` + +- [ ] **Step 2: Write the failing test** — `test/stealth.test.ts` + +```ts +import { describe, it, expect } from 'vitest' +import { PublicKey } from '@solana/web3.js' +import { xchacha20poly1305 } from '@noble/ciphers/chacha.js' +import { parseStealthMetaAddress, assembleWithdrawArtifacts } from '../src/stealth.js' +import { hexToBytes } from '../src/hex.js' + +const VIEWING = 'ab'.repeat(32) +const SPENDING = 'cd'.repeat(32) +const URI = `sip:solana:0x${SPENDING}:0x${VIEWING}` + +describe('parseStealthMetaAddress', () => { + it('parses a valid sip:solana URI', () => { + const m = parseStealthMetaAddress(URI) + expect(m).toEqual({ spendingKey: `0x${SPENDING}`, viewingKey: `0x${VIEWING}`, chain: 'solana' }) + }) + it('rejects a malformed URI (wrong parts count)', () => { + expect(() => parseStealthMetaAddress('sip:solana:0xabc')).toThrow('Invalid stealth meta-address') + }) + it('rejects non-0x keys', () => { + expect(() => parseStealthMetaAddress(`sip:solana:${SPENDING}:${VIEWING}`)).toThrow('0x-prefixed') + }) +}) + +describe('assembleWithdrawArtifacts', () => { + const recipient = parseStealthMetaAddress(URI) + + it('produces correctly-sized artifacts', () => { + const a = assembleWithdrawArtifacts(recipient, 2_000_000n) + expect(a.stealthPubkey).toBeInstanceOf(PublicKey) + expect(a.amountCommitment.length).toBe(33) + expect(a.ephemeralPubkey.length).toBe(33) + expect(a.ephemeralPubkey[0]).toBe(0x00) // ed25519 32B padded with 0x00 prefix + expect(a.viewingKeyHash.length).toBe(32) + expect(a.proof.length).toBe(0) + // encryptedAmount = 24 (nonce) + 40 (plaintext) + 16 (poly1305 tag) = 80 + expect(a.encryptedAmount.length).toBe(80) + }) + + it('encrypts [amount LE || blinding] recoverable with the viewing-key hash', () => { + const amount = 2_000_000n + const a = assembleWithdrawArtifacts(recipient, amount) + const nonce = a.encryptedAmount.slice(0, 24) + const ct = a.encryptedAmount.slice(24) + const plaintext = xchacha20poly1305(a.viewingKeyHash, nonce).decrypt(ct) + expect(plaintext.length).toBe(40) + // first 8 bytes = amount LE + let recovered = 0n + for (let i = 7; i >= 0; i--) recovered = (recovered << 8n) | BigInt(plaintext[i]) + expect(recovered).toBe(amount) + }) + + it('uses the viewing key from the recipient (hash matches sha256(viewingKey bytes))', async () => { + const { sha256 } = await import('@noble/hashes/sha2.js') + const a = assembleWithdrawArtifacts(recipient, 1_000_000n) + expect(Array.from(a.viewingKeyHash)).toEqual(Array.from(sha256(hexToBytes(`0x${VIEWING}`)))) + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm --filter @sipher/example-vault-privacy-provider test test/stealth.test.ts` +Expected: FAIL — cannot resolve `../src/stealth.js`. + +- [ ] **Step 4: Implement `src/stealth.ts`** + +```ts +import { PublicKey } from '@solana/web3.js' +import { + generateEd25519StealthAddress, + ed25519PublicKeyToSolanaAddress, + commit, +} from '@sip-protocol/sdk' +import { sha256 } from '@noble/hashes/sha2.js' +import { xchacha20poly1305 } from '@noble/ciphers/chacha.js' +import { randomBytes as cryptoRandomBytes } from 'node:crypto' +import { hexToBytes, bigintToLeBytes } from './hex.js' +import type { StealthMetaAddress, WithdrawArtifacts } from './types.js' + +/** Parse a `sip:solana:0x:0x` URI into a StealthMetaAddress. */ +export function parseStealthMetaAddress(uri: string): StealthMetaAddress { + const parts = uri.split(':') + if (parts.length !== 4 || parts[0] !== 'sip' || parts[1] !== 'solana' || !parts[2] || !parts[3]) { + throw new Error(`Invalid stealth meta-address: expected sip:solana::, got ${uri}`) + } + if (!parts[2].startsWith('0x') || !parts[3].startsWith('0x')) { + throw new Error('Stealth meta-address keys must be 0x-prefixed hex strings') + } + return { spendingKey: parts[2] as `0x${string}`, viewingKey: parts[3] as `0x${string}`, chain: 'solana' } +} + +/** + * Assemble the native-SOL private-withdraw crypto artifacts for a recipient. + * + * Mirrors the agent private-send assembly (packages/agent/src/tools/send.ts), + * minus the token-account derivation: native SOL pays a plain SystemAccount. + * + * Honesty: the Pedersen commitment is recorded for disclosure/audit. It does NOT + * hide the on-chain lamport delta — amounts are visible (TIER_1). + */ +export function assembleWithdrawArtifacts( + recipient: StealthMetaAddress, + amountLamports: bigint, +): WithdrawArtifacts { + // 1. One-time stealth address + ephemeral key from the recipient meta-address. + const stealth = generateEd25519StealthAddress(recipient) + const stealthPubkey = new PublicKey(ed25519PublicKeyToSolanaAddress(stealth.stealthAddress.address)) + + // 2. Real Pedersen commitment: C = amount*G + blinding*H. + const commitResult = commit(amountLamports) + const amountCommitment = hexToBytes(commitResult.commitment) + const blinding = commitResult.blinding + + // 3. Ephemeral pubkey: 32-byte ed25519 padded to 33 bytes with a 0x00 prefix + // (the program stores it opaquely for the scanner; it does not validate the curve). + const ephRaw = hexToBytes(stealth.stealthAddress.ephemeralPublicKey) + const ephemeralPubkey = new Uint8Array(33) + ephemeralPubkey[0] = 0x00 + ephemeralPubkey.set(ephRaw, 1) + + // 4. Viewing-key hash. + const viewingKeyHash = sha256(hexToBytes(recipient.viewingKey)) + + // 5. Encrypt [amount LE(8) || blinding(32)] with XChaCha20-Poly1305 under the + // viewing-key hash; prepend the 24-byte nonce so the recipient can decrypt. + const amountLeBytes = bigintToLeBytes(amountLamports) + const blindingBytes = hexToBytes(blinding) + const plaintext = new Uint8Array(amountLeBytes.length + blindingBytes.length) + plaintext.set(amountLeBytes, 0) + plaintext.set(blindingBytes, amountLeBytes.length) + const nonce = new Uint8Array(cryptoRandomBytes(24)) + const ciphertext = xchacha20poly1305(viewingKeyHash, nonce).encrypt(plaintext) + const encryptedAmount = new Uint8Array(nonce.length + ciphertext.length) + encryptedAmount.set(nonce, 0) + encryptedAmount.set(ciphertext, nonce.length) + + return { + stealthPubkey, + amountCommitment, + ephemeralPubkey, + viewingKeyHash, + encryptedAmount, + proof: new Uint8Array(0), + } +} +``` + +> Note: if `commit()` returns a bare (non-0x) hex string, `hexToBytes` handles both; the blinding is likewise parsed via `hexToBytes`. If the assertion in Step 2 about `commitResult.commitment` being 33 bytes fails, inspect the actual return shape of `commit` in `@sip-protocol/sdk` and adjust the parse (it returns `{ commitment, blinding }` as hex) — do not change the test's 33-byte expectation, which matches the on-chain layout. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm --filter @sipher/example-vault-privacy-provider test test/stealth.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 6: Commit** + +```bash +git add examples/vault-privacy-provider/src/types.ts examples/vault-privacy-provider/src/stealth.ts examples/vault-privacy-provider/test/stealth.test.ts +git commit -m "feat(example): stealth meta-address parse + withdraw-artifact assembly" +``` + +--- + +## Task 3: Provider — interface + funding/verify/deposit/refund/preview + +**Files:** +- Modify: `examples/vault-privacy-provider/src/types.ts` (add the interface + result types) +- Create: `examples/vault-privacy-provider/src/provider.ts` +- Test: `examples/vault-privacy-provider/test/provider.test.ts` + +**Interfaces:** +- Consumes: `buildDepositSolTx`, `buildRefundSolTx`, `DEFAULT_FEE_BPS` from `@sipher/sdk`; `Connection`, `Keypair`, `PublicKey`, `SystemProgram`, `Transaction` from `@solana/web3.js`. +- Produces (`src/types.ts`): `DepositResult { txSignature: string; depositedLamports: bigint }`, `PrivateWithdrawResult { txSignature: string; withdrawnLamports: bigint; feeLamports: bigint; stealthAddress: string }`, `RefundResult { txSignature: string; refundedLamports: bigint }`, `interface VaultPrivacyProvider`. +- Produces (`src/provider.ts`): `class SipherVaultPrivacyProvider implements VaultPrivacyProvider` with constructor `(connection: Connection, opts?: { feeBps?: number })`. + +- [ ] **Step 1: Add interface + result types to `src/types.ts`** + +Append to `src/types.ts`: +```ts +import type { Transaction, Keypair } from '@solana/web3.js' + +export interface DepositResult { txSignature: string; depositedLamports: bigint } +export interface PrivateWithdrawResult { + txSignature: string + withdrawnLamports: bigint + feeLamports: bigint + stealthAddress: string +} +export interface RefundResult { txSignature: string; refundedLamports: bigint } + +/** + * A pluggable privacy backend. The same shared depositor keypair MUST be passed to + * every fund-moving call — a per-user depositor would link each user's deposit and + * withdrawal on-chain and destroy the commingling anonymity property. + */ +export interface VaultPrivacyProvider { + /** Advertised withdraw fee (bps). The actual deducted fee comes from on-chain config. */ + readonly feeBps: number + buildFundingTx(args: { + fromPk: string; depositorPk: string; amountLamports: bigint; recentBlockhash: string + }): Promise + verifyFunding(args: { depositorPk: string; expectedLamports: bigint; txSignature: string }): Promise + deposit(args: { depositorKp: Keypair; lamports: bigint }): Promise + privateWithdraw(args: { + depositorKp: Keypair; recipient: StealthMetaAddress; lamports: bigint + }): Promise + refund(args: { depositorKp: Keypair }): Promise + previewWithdraw(grossLamports: bigint): { feeLamports: bigint; netLamports: bigint } +} +``` + +- [ ] **Step 2: Write the failing test** — `test/provider.test.ts` (funding/verify/deposit/refund/preview) + +```ts +import { describe, it, expect } from 'vitest' +import { + Connection, Keypair, PublicKey, SystemProgram, Transaction, +} from '@solana/web3.js' +import { + deriveVaultConfigPDA, deriveDepositRecordPDA, deriveSolVaultPDA, + anchorDiscriminator, NATIVE_SOL_MINT, +} from '@sipher/sdk' +import { SipherVaultPrivacyProvider } from '../src/provider.js' + +const BLOCKHASH = 'GfVcyD4kkTrj4bKc7WA9sZCin9JDbdT458zqL4zjxx2v' +const DEPOSITOR_KP = Keypair.generate() + +// Mock Connection: dispatches getAccountInfo by pubkey (config carries fee_bps at +// offset 40); records the last raw tx submitted; returns a deterministic signature. +function mockConn(opts: { feeBps?: number; recordBalance?: bigint } = {}): Connection { + const { feeBps = 10, recordBalance = 5_000_000n } = opts + const configBuf = Buffer.alloc(60); configBuf.writeUInt16LE(feeBps, 40) + // DepositRecord layout: disc(8) + depositor(32) + mint(32) + balance(u64 LE) + const recordBuf = Buffer.alloc(8 + 32 + 32 + 8); recordBuf.writeBigUInt64LE(recordBalance, 72) + const [cfg] = deriveVaultConfigPDA() + const [rec] = deriveDepositRecordPDA(DEPOSITOR_KP.publicKey, NATIVE_SOL_MINT) + return { + getLatestBlockhash: async () => ({ blockhash: BLOCKHASH, lastValidBlockHeight: 1 }), + getMinimumBalanceForRentExemption: async () => 890_880, + getAccountInfo: async (pk: PublicKey) => { + if (pk.equals(cfg)) return { data: configBuf } as never + if (pk.equals(rec)) return { data: recordBuf } as never + return { lamports: 1_000_000_000, data: Buffer.alloc(0) } as never + }, + getTransaction: async () => ({ meta: { err: null } }) as never, + sendRawTransaction: async () => 'SIG_' + BLOCKHASH.slice(0, 8), + confirmTransaction: async () => ({ value: { err: null } }) as never, + } as unknown as Connection +} + +describe('SipherVaultPrivacyProvider — funding/verify/deposit/refund/preview', () => { + it('feeBps defaults to the vault default and previewWithdraw splits fee/net', () => { + const p = new SipherVaultPrivacyProvider(mockConn()) + expect(p.feeBps).toBe(10) + expect(p.previewWithdraw(2_000_000n)).toEqual({ feeLamports: 2_000n, netLamports: 1_998_000n }) + }) + + it('buildFundingTx is a plain SystemProgram.transfer to the depositor wallet', async () => { + const p = new SipherVaultPrivacyProvider(mockConn()) + const from = Keypair.generate().publicKey.toBase58() + const dep = DEPOSITOR_KP.publicKey.toBase58() + const tx = await p.buildFundingTx({ fromPk: from, depositorPk: dep, amountLamports: 3_000_000n, recentBlockhash: BLOCKHASH }) + const ix = tx.instructions[0] + expect(ix.programId.equals(SystemProgram.programId)).toBe(true) + expect(ix.keys[0].pubkey.toBase58()).toBe(from) + expect(ix.keys[1].pubkey.toBase58()).toBe(dep) + }) + + it('verifyFunding throws when the tx is missing', async () => { + const conn = mockConn() + ;(conn as unknown as { getTransaction: () => Promise }).getTransaction = async () => null + const p = new SipherVaultPrivacyProvider(conn) + await expect(p.verifyFunding({ depositorPk: DEPOSITOR_KP.publicKey.toBase58(), expectedLamports: 1n, txSignature: 'x' })) + .rejects.toThrow('not found') + }) + + it('deposit builds deposit_sol, signs with the depositor, and returns the signature', async () => { + const p = new SipherVaultPrivacyProvider(mockConn()) + const res = await p.deposit({ depositorKp: DEPOSITOR_KP, lamports: 4_000_000n }) + expect(res.depositedLamports).toBe(4_000_000n) + expect(res.txSignature).toMatch(/^SIG_/) + }) + + it('refund returns the on-chain record balance', async () => { + const p = new SipherVaultPrivacyProvider(mockConn({ recordBalance: 4_242n })) + const res = await p.refund({ depositorKp: DEPOSITOR_KP }) + expect(res.refundedLamports).toBe(4_242n) + expect(res.txSignature).toMatch(/^SIG_/) + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm --filter @sipher/example-vault-privacy-provider test test/provider.test.ts` +Expected: FAIL — cannot resolve `../src/provider.js`. + +- [ ] **Step 4: Implement `src/provider.ts` (without privateWithdraw yet)** + +```ts +import { + Connection, Keypair, PublicKey, SystemProgram, Transaction, +} from '@solana/web3.js' +import { + buildDepositSolTx, buildRefundSolTx, DEFAULT_FEE_BPS, +} from '@sipher/sdk' +import type { + VaultPrivacyProvider, DepositResult, PrivateWithdrawResult, RefundResult, StealthMetaAddress, +} from './types.js' + +/** + * Reference privacy provider backed by the sipher vault (native SOL). + * + * Privacy model: commingling/decorrelation, NOT a cryptographic graph-break. The + * shared depositor signature links the depositor to each payout on-chain; amounts + * are visible (TIER_1). Unlinkability comes from many users sharing the depositor + * + batching/jitter — supply ONE shared depositor keypair to every call. + */ +export class SipherVaultPrivacyProvider implements VaultPrivacyProvider { + readonly feeBps: number + constructor(private readonly connection: Connection, opts: { feeBps?: number } = {}) { + this.feeBps = opts.feeBps ?? DEFAULT_FEE_BPS + } + + private async signAndSubmit(tx: Transaction, signer: Keypair): Promise { + tx.sign(signer) + const sig = await this.connection.sendRawTransaction(tx.serialize()) + await this.connection.confirmTransaction(sig, 'confirmed') + return sig + } + + async buildFundingTx(args: { + fromPk: string; depositorPk: string; amountLamports: bigint; recentBlockhash: string + }): Promise { + const tx = new Transaction() + tx.feePayer = new PublicKey(args.fromPk) + tx.recentBlockhash = args.recentBlockhash + tx.add(SystemProgram.transfer({ + fromPubkey: new PublicKey(args.fromPk), + toPubkey: new PublicKey(args.depositorPk), + lamports: Number(args.amountLamports), + })) + return tx + } + + async verifyFunding(args: { depositorPk: string; expectedLamports: bigint; txSignature: string }): Promise { + const tx = await this.connection.getTransaction(args.txSignature, { + commitment: 'confirmed', maxSupportedTransactionVersion: 0, + }) + if (!tx) throw new Error(`Funding transaction ${args.txSignature} not found or not yet confirmed`) + if (tx.meta?.err) throw new Error(`Funding transaction ${args.txSignature} failed: ${JSON.stringify(tx.meta.err)}`) + // NOTE: production should additionally assert the credited lamport delta on + // depositorPk equals expectedLamports by inspecting tx.meta pre/post balances. + } + + async deposit(args: { depositorKp: Keypair; lamports: bigint }): Promise { + const { transaction } = await buildDepositSolTx(this.connection, args.depositorKp.publicKey, args.lamports) + const txSignature = await this.signAndSubmit(transaction, args.depositorKp) + return { txSignature, depositedLamports: args.lamports } + } + + async refund(args: { depositorKp: Keypair }): Promise { + const { transaction, refundAmount } = await buildRefundSolTx(this.connection, args.depositorKp.publicKey) + const txSignature = await this.signAndSubmit(transaction, args.depositorKp) + return { txSignature, refundedLamports: refundAmount } + } + + previewWithdraw(grossLamports: bigint): { feeLamports: bigint; netLamports: bigint } { + const feeLamports = (grossLamports * BigInt(this.feeBps)) / 10_000n + return { feeLamports, netLamports: grossLamports - feeLamports } + } + + // privateWithdraw is added in Task 4. + async privateWithdraw(_args: { + depositorKp: Keypair; recipient: StealthMetaAddress; lamports: bigint + }): Promise { + throw new Error('not implemented') + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm --filter @sipher/example-vault-privacy-provider test test/provider.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 6: Commit** + +```bash +git add examples/vault-privacy-provider/src/types.ts examples/vault-privacy-provider/src/provider.ts examples/vault-privacy-provider/test/provider.test.ts +git commit -m "feat(example): provider interface + funding/verify/deposit/refund/preview" +``` + +--- + +## Task 4: Provider — privateWithdraw + depositor-as-vault & rent-guard invariants + +**Files:** +- Modify: `examples/vault-privacy-provider/src/provider.ts` (implement `privateWithdraw`) +- Modify: `examples/vault-privacy-provider/test/provider.test.ts` (add a describe block) + +**Interfaces:** +- Consumes: `assembleWithdrawArtifacts` (Task 2); `buildPrivateSendSolTx`, `deriveDepositRecordPDA`, `deriveSolVaultPDA`, `deriveSolFeePDA`, `anchorDiscriminator`, `NATIVE_SOL_MINT`, `SIP_PRIVACY_PROGRAM_ID` from `@sipher/sdk`. +- Produces: working `SipherVaultPrivacyProvider.privateWithdraw`. + +- [ ] **Step 1: Write the failing tests** — append to `test/provider.test.ts` + +```ts +import { parseStealthMetaAddress } from '../src/stealth.js' +import { buildPrivateSendSolTx, deriveSolFeePDA } from '@sipher/sdk' // (add to existing imports) + +const RECIPIENT = parseStealthMetaAddress(`sip:solana:0x${'cd'.repeat(32)}:0x${'ab'.repeat(32)}`) + +describe('SipherVaultPrivacyProvider — privateWithdraw', () => { + it('builds withdraw_private_sol to a derived stealth recipient and returns fee/net', async () => { + const p = new SipherVaultPrivacyProvider(mockConn({ feeBps: 10 })) + const res = await p.privateWithdraw({ depositorKp: DEPOSITOR_KP, recipient: RECIPIENT, lamports: 2_000_000n }) + expect(res.feeLamports).toBe(2_000n) + expect(res.withdrawnLamports).toBe(1_998_000n) + expect(res.txSignature).toMatch(/^SIG_/) + // stealthAddress is a derived one-time address (not the depositor) + expect(res.stealthAddress).not.toBe(DEPOSITOR_KP.publicKey.toBase58()) + expect(() => new PublicKey(res.stealthAddress)).not.toThrow() + }) + + it('reuses the SAME shared depositor across two flows (depositor-as-vault invariant)', async () => { + const p = new SipherVaultPrivacyProvider(mockConn()) + const seen: string[] = [] + const conn = (p as unknown as { connection: Connection }).connection + ;(conn as unknown as { sendRawTransaction: (raw: Uint8Array) => Promise }).sendRawTransaction = + async (raw: Uint8Array) => { + const tx = Transaction.from(raw) + // the depositor is the fee payer + the only signer on withdraw_private_sol + seen.push(tx.feePayer!.toBase58()) + return 'SIG_x' + } + await p.privateWithdraw({ depositorKp: DEPOSITOR_KP, recipient: RECIPIENT, lamports: 2_000_000n }) + await p.privateWithdraw({ depositorKp: DEPOSITOR_KP, recipient: RECIPIENT, lamports: 1_500_000n }) + expect(seen).toEqual([DEPOSITOR_KP.publicKey.toBase58(), DEPOSITOR_KP.publicKey.toBase58()]) + }) + + it('propagates the rent-exempt guard for a tiny payout to a fresh stealth', async () => { + // stealth account does not exist (0 lamports); a 1000-lamport net is below the floor + const conn = mockConn() + ;(conn as unknown as { getAccountInfo: (pk: PublicKey) => Promise }).getAccountInfo = + (() => { + const base = mockConn() + const orig = base.getAccountInfo.bind(base) + return async (pk: PublicKey) => { + const [cfg] = deriveVaultConfigPDA() + const [rec] = deriveDepositRecordPDA(DEPOSITOR_KP.publicKey, NATIVE_SOL_MINT) + if (pk.equals(cfg) || pk.equals(rec)) return orig(pk) + return null // stealth + everything else: not found + } + })() + const p = new SipherVaultPrivacyProvider(conn) + await expect(p.privateWithdraw({ depositorKp: DEPOSITOR_KP, recipient: RECIPIENT, lamports: 1_000n })) + .rejects.toThrow('rent-exempt minimum') + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @sipher/example-vault-privacy-provider test test/provider.test.ts` +Expected: FAIL — `privateWithdraw` throws 'not implemented'. + +- [ ] **Step 3: Implement `privateWithdraw` in `src/provider.ts`** + +Add `buildPrivateSendSolTx` to the `@sipher/sdk` import, `assembleWithdrawArtifacts` from `./stealth.js`, then replace the stub: +```ts +import { buildPrivateSendSolTx } from '@sipher/sdk' // add to existing @sipher/sdk import +import { assembleWithdrawArtifacts } from './stealth.js' // new import + + async privateWithdraw(args: { + depositorKp: Keypair; recipient: StealthMetaAddress; lamports: bigint + }): Promise { + const a = assembleWithdrawArtifacts(args.recipient, args.lamports) + const { transaction, netAmount, feeAmount, stealthAddress } = await buildPrivateSendSolTx({ + connection: this.connection, + depositor: args.depositorKp.publicKey, + amount: args.lamports, + stealthPubkey: a.stealthPubkey, + amountCommitment: a.amountCommitment, + ephemeralPubkey: a.ephemeralPubkey, + viewingKeyHash: a.viewingKeyHash, + encryptedAmount: a.encryptedAmount, + proof: a.proof, + }) + const txSignature = await this.signAndSubmit(transaction, args.depositorKp) + return { + txSignature, + withdrawnLamports: netAmount, + feeLamports: feeAmount, + stealthAddress: stealthAddress.toBase58(), + } + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @sipher/example-vault-privacy-provider test test/provider.test.ts` +Expected: PASS (all provider tests, incl. the 3 new ones). + +> If `Transaction.from(raw)` in the invariant test cannot parse the serialized tx (signature/blockhash quirk), assert the depositor differently: have the mock `sendRawTransaction` capture nothing and instead assert on `res.stealthAddress` uniqueness across the two calls AND that both calls accept the same `DEPOSITOR_KP` without error — the production invariant (depositor is the sole signer) is already enforced by `buildPrivateSendSolTx`'s account layout, verified in `packages/sdk/tests/privacy-sol.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add examples/vault-privacy-provider/src/provider.ts examples/vault-privacy-provider/test/provider.test.ts +git commit -m "feat(example): implement privateWithdraw + depositor-as-vault & rent-guard tests" +``` + +--- + +## Task 5: Barrel, README, and final gates + +**Files:** +- Create: `examples/vault-privacy-provider/src/index.ts` +- Create: `examples/vault-privacy-provider/README.md` + +**Interfaces:** +- Produces: a public barrel re-exporting the provider, interface, types, and assembly helpers. + +- [ ] **Step 1: Create `src/index.ts` (barrel)** + +```ts +export { SipherVaultPrivacyProvider } from './provider.js' +export { parseStealthMetaAddress, assembleWithdrawArtifacts } from './stealth.js' +export { hexToBytes, bigintToLeBytes } from './hex.js' +export type { + VaultPrivacyProvider, StealthMetaAddress, WithdrawArtifacts, + DepositResult, PrivateWithdrawResult, RefundResult, +} from './types.js' +``` + +- [ ] **Step 2: Add a barrel smoke test** — append to `test/provider.test.ts` + +```ts +describe('barrel', () => { + it('re-exports the public surface', async () => { + const m = await import('../src/index.js') + expect(typeof m.SipherVaultPrivacyProvider).toBe('function') + expect(typeof m.assembleWithdrawArtifacts).toBe('function') + expect(typeof m.parseStealthMetaAddress).toBe('function') + }) +}) +``` + +- [ ] **Step 3: Create `README.md` (generic, naming-clean)** + +````markdown +# Vault Privacy Provider (reference example) + +Back a pluggable **privacy-provider** interface with the `sipher_vault` program's +native-SOL operations. An application keeps its own "make this transfer private" +abstraction and swaps in the vault underneath. + +## What it shows +- A neutral `VaultPrivacyProvider` interface (`buildFundingTx`, `verifyFunding`, + `deposit`, `privateWithdraw`, `refund`, `previewWithdraw`). +- `SipherVaultPrivacyProvider`, backed entirely by `@sipher/sdk` native-SOL + builders (`buildDepositSolTx`, `buildPrivateSendSolTx`, `buildRefundSolTx`). +- The full private-withdraw assembly: a one-time stealth address, a Pedersen + commitment, and viewing-key encryption (via `@sip-protocol/sdk`) — the part you + most need to see, because the SDK withdraw builder is intentionally low-level. + +## Depositor-as-vault (read this) +Every deposit/withdraw/refund is signed by **one shared depositor wallet**, reused +across all users' flows. On-chain you see only `shared-depositor -> stealth_N`; the +user-to-recipient map lives in your own off-chain records. **Do not use a per-user +depositor** — it would link each user's deposit and withdrawal on-chain. + +## Honest privacy model +- **Commingling / decorrelation, not a cryptographic graph-break.** The depositor + signature links the shared depositor to each payout on-chain. Unlinkability comes + from many users sharing the depositor plus batching/jitter — not zero-knowledge. +- **Amounts are visible.** The Pedersen commitment is recorded for + disclosure/audit; the lamport delta is on-chain (TIER_1 in the SDK's privacy-tier + model). Use the SDK's `assessFlowPrivacy` to score a flow honestly. +- **Rent-exempt guard.** A one-time stealth recipient is a plain system account; + `buildPrivateSendSolTx` rejects a payout that would leave it below the + rent-exempt minimum. Pre-fund the stealth (or fund it on the funding leg). + +## Run the tests +```bash +pnpm --filter @sipher/sdk build # the example imports the built SDK +pnpm --filter @sipher/example-vault-privacy-provider test +``` + +## SPL / Token-2022 extension +The vault also supports classic SPL and Token-2022. The analogous path swaps the +`*Sol*` builders for `buildDepositTx` / `buildPrivateSendTx` with a `mint` + token +program and derives the stealth recipient's associated token account — otherwise +the shape is identical. +```` + +- [ ] **Step 4: Run the full example suite + typecheck** + +```bash +pnpm --filter @sipher/example-vault-privacy-provider test +pnpm --filter @sipher/example-vault-privacy-provider typecheck +``` +Expected: all tests PASS; typecheck clean (no errors). + +- [ ] **Step 5: Naming-gate grep (must be empty)** + +Run the deny-list grep from the execution notes over `examples/vault-privacy-provider` +(the literal pattern is kept out of this public file so it does not name the +parties). Expected: no output. + +- [ ] **Step 6: Commit** + +```bash +git add examples/vault-privacy-provider/src/index.ts examples/vault-privacy-provider/README.md examples/vault-privacy-provider/test/provider.test.ts +git commit -m "feat(example): barrel + README + final gates" +``` + +--- + +## Self-Review (completed during planning) + +- **Spec coverage:** interface (§3→Task 3), SDK mapping (§4→Tasks 3-4), depositor-as-vault + caveats (§5→Task 4 test + README), withdraw assembly (§6→Task 2), error/fee (§7→Task 3/4), native-SOL scope (§8→README), testing (§9→all tasks), file layout (§10→file structure). All covered. +- **Placeholder scan:** every code/test step carries real code; the two `> Note` blocks are fallback guidance, not deferred work. +- **Type consistency:** `StealthMetaAddress`/`WithdrawArtifacts` (Task 2) consumed unchanged in Tasks 3-4; provider result types (`DepositResult`/`PrivateWithdrawResult`/`RefundResult`) defined in Task 3 and returned exactly; `assembleWithdrawArtifacts` signature matches its call site; SDK symbol names verified against `origin/main` barrel. From 0d9e5f6522e331723e094299b7dd9a29246f335e Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 25 Jun 2026 15:09:35 +0700 Subject: [PATCH 3/8] feat(example): scaffold vault-privacy-provider package + hex helpers --- examples/vault-privacy-provider/package.json | 22 ++++++++++++++++ examples/vault-privacy-provider/src/hex.ts | 21 ++++++++++++++++ .../vault-privacy-provider/test/hex.test.ts | 23 +++++++++++++++++ examples/vault-privacy-provider/tsconfig.json | 15 +++++++++++ .../vault-privacy-provider/vitest.config.ts | 9 +++++++ pnpm-lock.yaml | 25 +++++++++++++++++++ pnpm-workspace.yaml | 1 + 7 files changed, 116 insertions(+) create mode 100644 examples/vault-privacy-provider/package.json create mode 100644 examples/vault-privacy-provider/src/hex.ts create mode 100644 examples/vault-privacy-provider/test/hex.test.ts create mode 100644 examples/vault-privacy-provider/tsconfig.json create mode 100644 examples/vault-privacy-provider/vitest.config.ts diff --git a/examples/vault-privacy-provider/package.json b/examples/vault-privacy-provider/package.json new file mode 100644 index 0000000..33e9c0a --- /dev/null +++ b/examples/vault-privacy-provider/package.json @@ -0,0 +1,22 @@ +{ + "name": "@sipher/example-vault-privacy-provider", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Reference: back a pluggable privacy-provider interface with the sipher vault (native SOL).", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sipher/sdk": "workspace:*", + "@sip-protocol/sdk": "^0.11.0", + "@solana/web3.js": "^1.98.0", + "@noble/hashes": "^2.0.0", + "@noble/ciphers": "^2.2.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^4.1.9" + } +} diff --git a/examples/vault-privacy-provider/src/hex.ts b/examples/vault-privacy-provider/src/hex.ts new file mode 100644 index 0000000..94aa0a7 --- /dev/null +++ b/examples/vault-privacy-provider/src/hex.ts @@ -0,0 +1,21 @@ +/** Convert an optionally 0x-prefixed hex string to bytes. */ +export function hexToBytes(hex: string): Uint8Array { + const h = hex.startsWith('0x') ? hex.slice(2) : hex + if (h.length % 2 !== 0) throw new Error(`Invalid hex length: ${hex}`) + const bytes = new Uint8Array(h.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +/** Convert a bigint to a little-endian byte array of `size` bytes. */ +export function bigintToLeBytes(value: bigint, size = 8): Uint8Array { + const buf = new Uint8Array(size) + let v = value + for (let i = 0; i < size; i++) { + buf[i] = Number(v & 0xffn) + v >>= 8n + } + return buf +} diff --git a/examples/vault-privacy-provider/test/hex.test.ts b/examples/vault-privacy-provider/test/hex.test.ts new file mode 100644 index 0000000..0db7575 --- /dev/null +++ b/examples/vault-privacy-provider/test/hex.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { hexToBytes, bigintToLeBytes } from '../src/hex.js' + +describe('hexToBytes', () => { + it('parses a 0x-prefixed hex string', () => { + expect(Array.from(hexToBytes('0xff00ab'))).toEqual([255, 0, 171]) + }) + it('parses a bare (no 0x) hex string', () => { + expect(Array.from(hexToBytes('ff00ab'))).toEqual([255, 0, 171]) + }) + it('throws on odd-length hex', () => { + expect(() => hexToBytes('0xabc')).toThrow('Invalid hex length') + }) +}) + +describe('bigintToLeBytes', () => { + it('encodes a bigint little-endian in 8 bytes by default', () => { + expect(Array.from(bigintToLeBytes(2_000_000n))).toEqual([128, 132, 30, 0, 0, 0, 0, 0]) + }) + it('honours an explicit size', () => { + expect(Array.from(bigintToLeBytes(1n, 4))).toEqual([1, 0, 0, 0]) + }) +}) diff --git a/examples/vault-privacy-provider/tsconfig.json b/examples/vault-privacy-provider/tsconfig.json new file mode 100644 index 0000000..07b51a3 --- /dev/null +++ b/examples/vault-privacy-provider/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/examples/vault-privacy-provider/vitest.config.ts b/examples/vault-privacy-provider/vitest.config.ts new file mode 100644 index 0000000..d9be7c8 --- /dev/null +++ b/examples/vault-privacy-provider/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d62dd1..e13dae2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,31 @@ importers: specifier: ^4.1.9 version: 4.1.9(@types/node@25.9.3)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + examples/vault-privacy-provider: + dependencies: + '@noble/ciphers': + specifier: ^2.2.0 + version: 2.2.0 + '@noble/hashes': + specifier: ^2.0.0 + version: 2.2.0 + '@sip-protocol/sdk': + specifier: ^0.11.0 + version: 0.11.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(openai@6.26.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.4.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod-to-json-schema@3.25.2(zod@4.4.3)) + '@sipher/sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@solana/web3.js': + specifier: ^1.98.0 + version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@25.9.3)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/agent: dependencies: '@mariozechner/pi-agent-core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index abb9c45..b3c24b6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - '.' - 'packages/*' - 'app' + - 'examples/*' - '!sdks/**' patchedDependencies: '@triton-one/yellowstone-grpc@4.0.2': patches/@triton-one__yellowstone-grpc@4.0.2.patch From 50345bdea2876ee91d11160df6ae9769fd7e7f7a Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 25 Jun 2026 15:17:17 +0700 Subject: [PATCH 4/8] feat(example): stealth meta-address parse + withdraw-artifact assembly --- .../vault-privacy-provider/src/stealth.ts | 80 +++++++++++++++++++ examples/vault-privacy-provider/src/types.ts | 24 ++++++ .../test/stealth.test.ts | 63 +++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 examples/vault-privacy-provider/src/stealth.ts create mode 100644 examples/vault-privacy-provider/src/types.ts create mode 100644 examples/vault-privacy-provider/test/stealth.test.ts diff --git a/examples/vault-privacy-provider/src/stealth.ts b/examples/vault-privacy-provider/src/stealth.ts new file mode 100644 index 0000000..44d0ed8 --- /dev/null +++ b/examples/vault-privacy-provider/src/stealth.ts @@ -0,0 +1,80 @@ +import { PublicKey } from '@solana/web3.js' +import { + generateEd25519StealthAddress, + ed25519PublicKeyToSolanaAddress, + commit, +} from '@sip-protocol/sdk' +import { sha256 } from '@noble/hashes/sha2.js' +import { xchacha20poly1305 } from '@noble/ciphers/chacha.js' +import { randomBytes as cryptoRandomBytes } from 'node:crypto' +import { hexToBytes, bigintToLeBytes } from './hex.js' +import type { StealthMetaAddress, WithdrawArtifacts } from './types.js' + +/** Parse a `sip:solana:0x:0x` URI into a StealthMetaAddress. */ +export function parseStealthMetaAddress(uri: string): StealthMetaAddress { + const parts = uri.split(':') + if (parts.length !== 4 || parts[0] !== 'sip' || parts[1] !== 'solana' || !parts[2] || !parts[3]) { + throw new Error(`Invalid stealth meta-address: expected sip:solana::, got ${uri}`) + } + if (!parts[2].startsWith('0x') || !parts[3].startsWith('0x')) { + throw new Error('Stealth meta-address keys must be 0x-prefixed hex strings') + } + return { spendingKey: parts[2] as `0x${string}`, viewingKey: parts[3] as `0x${string}`, chain: 'solana' } +} + +/** + * Assemble the native-SOL private-withdraw crypto artifacts for a recipient. + * + * Mirrors the agent private-send assembly (packages/agent/src/tools/send.ts), + * minus the token-account derivation: native SOL pays a plain SystemAccount. + * + * Honesty: the Pedersen commitment is recorded for disclosure/audit. It does NOT + * hide the on-chain lamport delta — amounts are visible (TIER_1). + */ +export function assembleWithdrawArtifacts( + recipient: StealthMetaAddress, + amountLamports: bigint, +): WithdrawArtifacts { + // 1. One-time stealth address + ephemeral key from the recipient meta-address. + const stealth = generateEd25519StealthAddress(recipient) + const stealthPubkey = new PublicKey(ed25519PublicKeyToSolanaAddress(stealth.stealthAddress.address)) + + // 2. Real Pedersen commitment: C = amount*G + blinding*H. + // commit() returns { commitment: HexString (compressed, 33 bytes), blinding: HexString (32 bytes) }. + const commitResult = commit(amountLamports) + const amountCommitment = hexToBytes(commitResult.commitment) + const blinding = commitResult.blinding + + // 3. Ephemeral pubkey: 32-byte ed25519 padded to 33 bytes with a 0x00 prefix. + // The on-chain program stores it opaquely for the scanner; it does not validate the curve. + const ephRaw = hexToBytes(stealth.stealthAddress.ephemeralPublicKey) + const ephemeralPubkey = new Uint8Array(33) + ephemeralPubkey[0] = 0x00 + ephemeralPubkey.set(ephRaw, 1) + + // 4. Viewing-key hash: SHA-256 of the raw viewing key bytes. + const viewingKeyHash = sha256(hexToBytes(recipient.viewingKey)) + + // 5. Encrypt [amount LE(8) || blinding(32)] with XChaCha20-Poly1305 under the + // viewing-key hash; prepend the 24-byte nonce so the recipient can decrypt. + // Layout: [24 bytes nonce] || [ciphertext + 16 bytes poly1305 tag] = 80 bytes total. + const amountLeBytes = bigintToLeBytes(amountLamports) + const blindingBytes = hexToBytes(blinding) + const plaintext = new Uint8Array(amountLeBytes.length + blindingBytes.length) + plaintext.set(amountLeBytes, 0) + plaintext.set(blindingBytes, amountLeBytes.length) + const nonce = new Uint8Array(cryptoRandomBytes(24)) + const ciphertext = xchacha20poly1305(viewingKeyHash, nonce).encrypt(plaintext) + const encryptedAmount = new Uint8Array(nonce.length + ciphertext.length) + encryptedAmount.set(nonce, 0) + encryptedAmount.set(ciphertext, nonce.length) + + return { + stealthPubkey, + amountCommitment, + ephemeralPubkey, + viewingKeyHash, + encryptedAmount, + proof: new Uint8Array(0), + } +} diff --git a/examples/vault-privacy-provider/src/types.ts b/examples/vault-privacy-provider/src/types.ts new file mode 100644 index 0000000..1adf572 --- /dev/null +++ b/examples/vault-privacy-provider/src/types.ts @@ -0,0 +1,24 @@ +import type { PublicKey } from '@solana/web3.js' + +/** A recipient's stealth meta-address: spending + viewing public keys (0x-hex). */ +export interface StealthMetaAddress { + spendingKey: `0x${string}` + viewingKey: `0x${string}` + chain: 'solana' +} + +/** On-chain crypto artifacts a native-SOL private withdrawal requires. */ +export interface WithdrawArtifacts { + /** One-time stealth recipient (a plain SystemAccount). */ + stealthPubkey: PublicKey + /** Pedersen commitment C = amount*G + blinding*H (33 bytes). */ + amountCommitment: Uint8Array + /** Ephemeral pubkey for ECDH, 33 bytes (ed25519 padded with a 0x00 prefix). */ + ephemeralPubkey: Uint8Array + /** SHA-256 of the viewing key (32 bytes). */ + viewingKeyHash: Uint8Array + /** AEAD blob: [24-byte nonce] || [ciphertext+tag] over [amount LE(8) || blinding(32)]. */ + encryptedAmount: Uint8Array + /** ZK proof (empty — verified off-chain). */ + proof: Uint8Array +} diff --git a/examples/vault-privacy-provider/test/stealth.test.ts b/examples/vault-privacy-provider/test/stealth.test.ts new file mode 100644 index 0000000..d67a37e --- /dev/null +++ b/examples/vault-privacy-provider/test/stealth.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { PublicKey } from '@solana/web3.js' +import { xchacha20poly1305 } from '@noble/ciphers/chacha.js' +import { parseStealthMetaAddress, assembleWithdrawArtifacts } from '../src/stealth.js' +import { hexToBytes } from '../src/hex.js' + +// Keys for parse-only tests — not required to be valid curve points. +const VIEWING = 'ab'.repeat(32) +const SPENDING = 'cd'.repeat(32) +const URI = `sip:solana:0x${SPENDING}:0x${VIEWING}` + +// Valid ed25519 compressed public keys for crypto tests (generated via generateEd25519StealthMetaAddress). +const VALID_SPENDING = 'bed3f000fd20cb20a8186ae9a9da609c8f01198e1c6e8c18f0bfff9c94f7d17e' +const VALID_VIEWING = '33e39eaad6d8924030fa1540ee80de1c212d68d12d53570eb3d6bb39aa4b15e4' +const VALID_URI = `sip:solana:0x${VALID_SPENDING}:0x${VALID_VIEWING}` + +describe('parseStealthMetaAddress', () => { + it('parses a valid sip:solana URI', () => { + const m = parseStealthMetaAddress(URI) + expect(m).toEqual({ spendingKey: `0x${SPENDING}`, viewingKey: `0x${VIEWING}`, chain: 'solana' }) + }) + it('rejects a malformed URI (wrong parts count)', () => { + expect(() => parseStealthMetaAddress('sip:solana:0xabc')).toThrow('Invalid stealth meta-address') + }) + it('rejects non-0x keys', () => { + expect(() => parseStealthMetaAddress(`sip:solana:${SPENDING}:${VIEWING}`)).toThrow('0x-prefixed') + }) +}) + +describe('assembleWithdrawArtifacts', () => { + const recipient = parseStealthMetaAddress(VALID_URI) + + it('produces correctly-sized artifacts', () => { + const a = assembleWithdrawArtifacts(recipient, 2_000_000n) + expect(a.stealthPubkey).toBeInstanceOf(PublicKey) + expect(a.amountCommitment.length).toBe(33) + expect(a.ephemeralPubkey.length).toBe(33) + expect(a.ephemeralPubkey[0]).toBe(0x00) // ed25519 32B padded with 0x00 prefix + expect(a.viewingKeyHash.length).toBe(32) + expect(a.proof.length).toBe(0) + // encryptedAmount = 24 (nonce) + 40 (plaintext) + 16 (poly1305 tag) = 80 + expect(a.encryptedAmount.length).toBe(80) + }) + + it('encrypts [amount LE || blinding] recoverable with the viewing-key hash', () => { + const amount = 2_000_000n + const a = assembleWithdrawArtifacts(recipient, amount) + const nonce = a.encryptedAmount.slice(0, 24) + const ct = a.encryptedAmount.slice(24) + const plaintext = xchacha20poly1305(a.viewingKeyHash, nonce).decrypt(ct) + expect(plaintext.length).toBe(40) + // first 8 bytes = amount LE + let recovered = 0n + for (let i = 7; i >= 0; i--) recovered = (recovered << 8n) | BigInt(plaintext[i]) + expect(recovered).toBe(amount) + }) + + it('uses the viewing key from the recipient (hash matches sha256(viewingKey bytes))', async () => { + const { sha256 } = await import('@noble/hashes/sha2.js') + const a = assembleWithdrawArtifacts(recipient, 1_000_000n) + expect(Array.from(a.viewingKeyHash)).toEqual(Array.from(sha256(hexToBytes(`0x${VALID_VIEWING}`)))) + }) +}) From 807382809cb029c102a9a2f45d8bb176fb2f6718 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 25 Jun 2026 15:25:41 +0700 Subject: [PATCH 5/8] feat(example): provider interface + funding/verify/deposit/refund/preview --- .../vault-privacy-provider/src/provider.ts | 91 +++++++++++++++++++ examples/vault-privacy-provider/src/types.ts | 31 ++++++- .../test/provider.test.ts | 80 ++++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 examples/vault-privacy-provider/src/provider.ts create mode 100644 examples/vault-privacy-provider/test/provider.test.ts diff --git a/examples/vault-privacy-provider/src/provider.ts b/examples/vault-privacy-provider/src/provider.ts new file mode 100644 index 0000000..06113a3 --- /dev/null +++ b/examples/vault-privacy-provider/src/provider.ts @@ -0,0 +1,91 @@ +import { + Connection, Keypair, PublicKey, SystemProgram, Transaction, +} from '@solana/web3.js' +import { + buildDepositSolTx, buildRefundSolTx, DEFAULT_FEE_BPS, +} from '@sipher/sdk' +import type { + VaultPrivacyProvider, DepositResult, PrivateWithdrawResult, RefundResult, StealthMetaAddress, +} from './types.js' + +/** + * Reference privacy provider backed by the sipher vault (native SOL). + * + * Privacy model: commingling/decorrelation, NOT a cryptographic graph-break. The + * shared depositor signature links the depositor to each payout on-chain; amounts + * are visible (TIER_1). Unlinkability comes from many users sharing the depositor + * + batching/jitter — supply ONE shared depositor keypair to every call. + */ +export class SipherVaultPrivacyProvider implements VaultPrivacyProvider { + readonly feeBps: number + + constructor(private readonly connection: Connection, opts: { feeBps?: number } = {}) { + this.feeBps = opts.feeBps ?? DEFAULT_FEE_BPS + } + + private async signAndSubmit(tx: Transaction, signer: Keypair): Promise { + tx.sign(signer) + const sig = await this.connection.sendRawTransaction(tx.serialize()) + await this.connection.confirmTransaction(sig, 'confirmed') + return sig + } + + async buildFundingTx(args: { + fromPk: string; depositorPk: string; amountLamports: bigint; recentBlockhash: string + }): Promise { + const tx = new Transaction() + tx.feePayer = new PublicKey(args.fromPk) + tx.recentBlockhash = args.recentBlockhash + tx.add(SystemProgram.transfer({ + fromPubkey: new PublicKey(args.fromPk), + toPubkey: new PublicKey(args.depositorPk), + lamports: Number(args.amountLamports), + })) + return tx + } + + async verifyFunding(args: { + depositorPk: string; expectedLamports: bigint; txSignature: string + }): Promise { + const tx = await this.connection.getTransaction(args.txSignature, { + commitment: 'confirmed', maxSupportedTransactionVersion: 0, + }) + if (!tx) throw new Error(`Funding transaction ${args.txSignature} not found or not yet confirmed`) + if (tx.meta?.err) { + throw new Error(`Funding transaction ${args.txSignature} failed: ${JSON.stringify(tx.meta.err)}`) + } + // NOTE: production should additionally assert the credited lamport delta on + // depositorPk equals expectedLamports by inspecting tx.meta pre/post balances. + } + + async deposit(args: { depositorKp: Keypair; lamports: bigint }): Promise { + const { transaction } = await buildDepositSolTx( + this.connection, + args.depositorKp.publicKey, + args.lamports, + ) + const txSignature = await this.signAndSubmit(transaction, args.depositorKp) + return { txSignature, depositedLamports: args.lamports } + } + + async refund(args: { depositorKp: Keypair }): Promise { + const { transaction, refundAmount } = await buildRefundSolTx( + this.connection, + args.depositorKp.publicKey, + ) + const txSignature = await this.signAndSubmit(transaction, args.depositorKp) + return { txSignature, refundedLamports: refundAmount } + } + + previewWithdraw(grossLamports: bigint): { feeLamports: bigint; netLamports: bigint } { + const feeLamports = (grossLamports * BigInt(this.feeBps)) / 10_000n + return { feeLamports, netLamports: grossLamports - feeLamports } + } + + // privateWithdraw is implemented in Task 4. + async privateWithdraw(_args: { + depositorKp: Keypair; recipient: StealthMetaAddress; lamports: bigint + }): Promise { + throw new Error('not implemented') + } +} diff --git a/examples/vault-privacy-provider/src/types.ts b/examples/vault-privacy-provider/src/types.ts index 1adf572..5f743f5 100644 --- a/examples/vault-privacy-provider/src/types.ts +++ b/examples/vault-privacy-provider/src/types.ts @@ -1,4 +1,4 @@ -import type { PublicKey } from '@solana/web3.js' +import type { PublicKey, Transaction, Keypair } from '@solana/web3.js' /** A recipient's stealth meta-address: spending + viewing public keys (0x-hex). */ export interface StealthMetaAddress { @@ -22,3 +22,32 @@ export interface WithdrawArtifacts { /** ZK proof (empty — verified off-chain). */ proof: Uint8Array } + +export interface DepositResult { txSignature: string; depositedLamports: bigint } +export interface PrivateWithdrawResult { + txSignature: string + withdrawnLamports: bigint + feeLamports: bigint + stealthAddress: string +} +export interface RefundResult { txSignature: string; refundedLamports: bigint } + +/** + * A pluggable privacy backend. The same shared depositor keypair MUST be passed to + * every fund-moving call — a per-user depositor would link each user's deposit and + * withdrawal on-chain and destroy the commingling anonymity property. + */ +export interface VaultPrivacyProvider { + /** Advertised withdraw fee (bps). The actual deducted fee comes from on-chain config. */ + readonly feeBps: number + buildFundingTx(args: { + fromPk: string; depositorPk: string; amountLamports: bigint; recentBlockhash: string + }): Promise + verifyFunding(args: { depositorPk: string; expectedLamports: bigint; txSignature: string }): Promise + deposit(args: { depositorKp: Keypair; lamports: bigint }): Promise + privateWithdraw(args: { + depositorKp: Keypair; recipient: StealthMetaAddress; lamports: bigint + }): Promise + refund(args: { depositorKp: Keypair }): Promise + previewWithdraw(grossLamports: bigint): { feeLamports: bigint; netLamports: bigint } +} diff --git a/examples/vault-privacy-provider/test/provider.test.ts b/examples/vault-privacy-provider/test/provider.test.ts new file mode 100644 index 0000000..14b33cc --- /dev/null +++ b/examples/vault-privacy-provider/test/provider.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { + Connection, Keypair, PublicKey, SystemProgram, Transaction, +} from '@solana/web3.js' +import { + deriveVaultConfigPDA, deriveDepositRecordPDA, NATIVE_SOL_MINT, +} from '@sipher/sdk' +import { SipherVaultPrivacyProvider } from '../src/provider.js' + +const BLOCKHASH = 'GfVcyD4kkTrj4bKc7WA9sZCin9JDbdT458zqL4zjxx2v' +const DEPOSITOR_KP = Keypair.generate() + +// Mock Connection: dispatches getAccountInfo by pubkey (config carries fee_bps at +// offset 40); records the last raw tx submitted; returns a deterministic signature. +function mockConn(opts: { feeBps?: number; recordBalance?: bigint } = {}): Connection { + const { feeBps = 10, recordBalance = 5_000_000n } = opts + // VaultConfig layout (60-byte fixed prefix, but deserializeVaultConfig needs 68 — pad to 68+): + // disc(8) + authority(32) + fee_bps(u16 at offset 40) + ... + const configBuf = Buffer.alloc(68); configBuf.writeUInt16LE(feeBps, 40) + // DepositRecord layout: disc(8)+depositor(32)+mint(32)+balance(u64 LE at 72)+ + // cumulativeVolume(u64)+lastDepositAt(i64)+bump(u8) = 97 bytes total. + // deserializeDepositRecord requires exactly 97 bytes minimum. + const recordBuf = Buffer.alloc(8 + 32 + 32 + 8 + 8 + 8 + 1) + recordBuf.writeBigUInt64LE(recordBalance, 72) + const [cfg] = deriveVaultConfigPDA() + const [rec] = deriveDepositRecordPDA(DEPOSITOR_KP.publicKey, NATIVE_SOL_MINT) + return { + getLatestBlockhash: async () => ({ blockhash: BLOCKHASH, lastValidBlockHeight: 1 }), + getMinimumBalanceForRentExemption: async () => 890_880, + getAccountInfo: async (pk: PublicKey) => { + if (pk.equals(cfg)) return { data: configBuf } as never + if (pk.equals(rec)) return { data: recordBuf } as never + return { lamports: 1_000_000_000, data: Buffer.alloc(0) } as never + }, + getTransaction: async () => ({ meta: { err: null } }) as never, + sendRawTransaction: async () => 'SIG_' + BLOCKHASH.slice(0, 8), + confirmTransaction: async () => ({ value: { err: null } }) as never, + } as unknown as Connection +} + +describe('SipherVaultPrivacyProvider — funding/verify/deposit/refund/preview', () => { + it('feeBps defaults to the vault default and previewWithdraw splits fee/net', () => { + const p = new SipherVaultPrivacyProvider(mockConn()) + expect(p.feeBps).toBe(10) + expect(p.previewWithdraw(2_000_000n)).toEqual({ feeLamports: 2_000n, netLamports: 1_998_000n }) + }) + + it('buildFundingTx is a plain SystemProgram.transfer to the depositor wallet', async () => { + const p = new SipherVaultPrivacyProvider(mockConn()) + const from = Keypair.generate().publicKey.toBase58() + const dep = DEPOSITOR_KP.publicKey.toBase58() + const tx = await p.buildFundingTx({ fromPk: from, depositorPk: dep, amountLamports: 3_000_000n, recentBlockhash: BLOCKHASH }) + const ix = tx.instructions[0] + expect(ix.programId.equals(SystemProgram.programId)).toBe(true) + expect(ix.keys[0].pubkey.toBase58()).toBe(from) + expect(ix.keys[1].pubkey.toBase58()).toBe(dep) + }) + + it('verifyFunding throws when the tx is missing', async () => { + const conn = mockConn() + ;(conn as unknown as { getTransaction: () => Promise }).getTransaction = async () => null + const p = new SipherVaultPrivacyProvider(conn) + await expect(p.verifyFunding({ depositorPk: DEPOSITOR_KP.publicKey.toBase58(), expectedLamports: 1n, txSignature: 'x' })) + .rejects.toThrow('not found') + }) + + it('deposit builds deposit_sol, signs with the depositor, and returns the signature', async () => { + const p = new SipherVaultPrivacyProvider(mockConn()) + const res = await p.deposit({ depositorKp: DEPOSITOR_KP, lamports: 4_000_000n }) + expect(res.depositedLamports).toBe(4_000_000n) + expect(res.txSignature).toMatch(/^SIG_/) + }) + + it('refund returns the on-chain record balance', async () => { + const p = new SipherVaultPrivacyProvider(mockConn({ recordBalance: 4_242n })) + const res = await p.refund({ depositorKp: DEPOSITOR_KP }) + expect(res.refundedLamports).toBe(4_242n) + expect(res.txSignature).toMatch(/^SIG_/) + }) +}) From 52526e3f80543ae2419779bbd891b44633b2913c Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 25 Jun 2026 15:33:17 +0700 Subject: [PATCH 6/8] feat(example): implement privateWithdraw + depositor-as-vault & rent-guard tests --- .../vault-privacy-provider/src/provider.ts | 26 ++++++-- .../test/provider.test.ts | 63 +++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/examples/vault-privacy-provider/src/provider.ts b/examples/vault-privacy-provider/src/provider.ts index 06113a3..f78cdb1 100644 --- a/examples/vault-privacy-provider/src/provider.ts +++ b/examples/vault-privacy-provider/src/provider.ts @@ -2,8 +2,9 @@ import { Connection, Keypair, PublicKey, SystemProgram, Transaction, } from '@solana/web3.js' import { - buildDepositSolTx, buildRefundSolTx, DEFAULT_FEE_BPS, + buildDepositSolTx, buildRefundSolTx, buildPrivateSendSolTx, DEFAULT_FEE_BPS, } from '@sipher/sdk' +import { assembleWithdrawArtifacts } from './stealth.js' import type { VaultPrivacyProvider, DepositResult, PrivateWithdrawResult, RefundResult, StealthMetaAddress, } from './types.js' @@ -82,10 +83,27 @@ export class SipherVaultPrivacyProvider implements VaultPrivacyProvider { return { feeLamports, netLamports: grossLamports - feeLamports } } - // privateWithdraw is implemented in Task 4. - async privateWithdraw(_args: { + async privateWithdraw(args: { depositorKp: Keypair; recipient: StealthMetaAddress; lamports: bigint }): Promise { - throw new Error('not implemented') + const a = assembleWithdrawArtifacts(args.recipient, args.lamports) + const { transaction, netAmount, feeAmount, stealthAddress } = await buildPrivateSendSolTx({ + connection: this.connection, + depositor: args.depositorKp.publicKey, + amount: args.lamports, + stealthPubkey: a.stealthPubkey, + amountCommitment: a.amountCommitment, + ephemeralPubkey: a.ephemeralPubkey, + viewingKeyHash: a.viewingKeyHash, + encryptedAmount: a.encryptedAmount, + proof: a.proof, + }) + const txSignature = await this.signAndSubmit(transaction, args.depositorKp) + return { + txSignature, + withdrawnLamports: netAmount, + feeLamports: feeAmount, + stealthAddress: stealthAddress.toBase58(), + } } } diff --git a/examples/vault-privacy-provider/test/provider.test.ts b/examples/vault-privacy-provider/test/provider.test.ts index 14b33cc..a81c1ce 100644 --- a/examples/vault-privacy-provider/test/provider.test.ts +++ b/examples/vault-privacy-provider/test/provider.test.ts @@ -6,6 +6,7 @@ import { deriveVaultConfigPDA, deriveDepositRecordPDA, NATIVE_SOL_MINT, } from '@sipher/sdk' import { SipherVaultPrivacyProvider } from '../src/provider.js' +import { parseStealthMetaAddress } from '../src/stealth.js' const BLOCKHASH = 'GfVcyD4kkTrj4bKc7WA9sZCin9JDbdT458zqL4zjxx2v' const DEPOSITOR_KP = Keypair.generate() @@ -78,3 +79,65 @@ describe('SipherVaultPrivacyProvider — funding/verify/deposit/refund/preview', expect(res.txSignature).toMatch(/^SIG_/) }) }) + +const VALID_SPENDING = 'bed3f000fd20cb20a8186ae9a9da609c8f01198e1c6e8c18f0bfff9c94f7d17e' +const VALID_VIEWING = '33e39eaad6d8924030fa1540ee80de1c212d68d12d53570eb3d6bb39aa4b15e4' +const RECIPIENT = parseStealthMetaAddress(`sip:solana:0x${VALID_SPENDING}:0x${VALID_VIEWING}`) + +describe('SipherVaultPrivacyProvider — privateWithdraw', () => { + it('builds withdraw_private_sol to a derived stealth recipient and returns fee/net', async () => { + const p = new SipherVaultPrivacyProvider(mockConn({ feeBps: 10 })) + const res = await p.privateWithdraw({ depositorKp: DEPOSITOR_KP, recipient: RECIPIENT, lamports: 2_000_000n }) + expect(res.feeLamports).toBe(2_000n) + expect(res.withdrawnLamports).toBe(1_998_000n) + expect(res.txSignature).toMatch(/^SIG_/) + // stealthAddress is a derived one-time address (not the depositor) + expect(res.stealthAddress).not.toBe(DEPOSITOR_KP.publicKey.toBase58()) + expect(() => new PublicKey(res.stealthAddress)).not.toThrow() + }) + + it('signs every flow with the SAME shared depositor (depositor-as-vault)', async () => { + const seen: string[] = [] + const conn = mockConn() + ;(conn as unknown as { sendRawTransaction: (raw: Uint8Array) => Promise }) + .sendRawTransaction = async (raw) => { + const tx = Transaction.from(raw) + expect(tx.signatures).toHaveLength(1) // depositor is the SOLE signer + seen.push(tx.feePayer!.toBase58()) + return 'SIG_x' + } + const p = new SipherVaultPrivacyProvider(conn) + await p.privateWithdraw({ depositorKp: DEPOSITOR_KP, recipient: RECIPIENT, lamports: 2_000_000n }) + await p.privateWithdraw({ depositorKp: DEPOSITOR_KP, recipient: RECIPIENT, lamports: 1_500_000n }) + expect(seen).toEqual([DEPOSITOR_KP.publicKey.toBase58(), DEPOSITOR_KP.publicKey.toBase58()]) + }) + + it('derives a unique one-time stealth address per flow', async () => { + const p = new SipherVaultPrivacyProvider(mockConn()) + const a = await p.privateWithdraw({ depositorKp: DEPOSITOR_KP, recipient: RECIPIENT, lamports: 2_000_000n }) + const b = await p.privateWithdraw({ depositorKp: DEPOSITOR_KP, recipient: RECIPIENT, lamports: 1_500_000n }) + expect(a.stealthAddress).not.toBe(b.stealthAddress) + expect(() => new PublicKey(a.stealthAddress)).not.toThrow() + expect(() => new PublicKey(b.stealthAddress)).not.toThrow() + expect(a.stealthAddress).not.toBe(DEPOSITOR_KP.publicKey.toBase58()) + }) + + it('propagates the rent-exempt guard for a tiny payout to a fresh stealth', async () => { + // stealth account does not exist (0 lamports); a 1000-lamport net is below the floor + const conn = mockConn() + ;(conn as unknown as { getAccountInfo: (pk: PublicKey) => Promise }).getAccountInfo = + (() => { + const base = mockConn() + const orig = base.getAccountInfo.bind(base) + return async (pk: PublicKey) => { + const [cfg] = deriveVaultConfigPDA() + const [rec] = deriveDepositRecordPDA(DEPOSITOR_KP.publicKey, NATIVE_SOL_MINT) + if (pk.equals(cfg) || pk.equals(rec)) return orig(pk) + return null // stealth + everything else: not found + } + })() + const p = new SipherVaultPrivacyProvider(conn) + await expect(p.privateWithdraw({ depositorKp: DEPOSITOR_KP, recipient: RECIPIENT, lamports: 1_000n })) + .rejects.toThrow('rent-exempt minimum') + }) +}) From 44736f56a7c3e2eb4bcfb1092d5163c8a685a317 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 25 Jun 2026 15:44:45 +0700 Subject: [PATCH 7/8] feat(example): barrel + README + final gates --- examples/vault-privacy-provider/README.md | 43 +++++++++++++++++++ examples/vault-privacy-provider/src/index.ts | 7 +++ .../test/provider.test.ts | 9 ++++ 3 files changed, 59 insertions(+) create mode 100644 examples/vault-privacy-provider/README.md create mode 100644 examples/vault-privacy-provider/src/index.ts diff --git a/examples/vault-privacy-provider/README.md b/examples/vault-privacy-provider/README.md new file mode 100644 index 0000000..0fa50e3 --- /dev/null +++ b/examples/vault-privacy-provider/README.md @@ -0,0 +1,43 @@ +# Vault Privacy Provider (reference example) + +Back a pluggable **privacy-provider** interface with the `sipher_vault` program's +native-SOL operations. An application keeps its own "make this transfer private" +abstraction and swaps in the vault underneath. + +## What it shows +- A neutral `VaultPrivacyProvider` interface (`buildFundingTx`, `verifyFunding`, + `deposit`, `privateWithdraw`, `refund`, `previewWithdraw`). +- `SipherVaultPrivacyProvider`, backed entirely by `@sipher/sdk` native-SOL + builders (`buildDepositSolTx`, `buildPrivateSendSolTx`, `buildRefundSolTx`). +- The full private-withdraw assembly: a one-time stealth address, a Pedersen + commitment, and viewing-key encryption (via `@sip-protocol/sdk`) — the part you + most need to see, because the SDK withdraw builder is intentionally low-level. + +## Depositor-as-vault (read this) +Every deposit/withdraw/refund is signed by **one shared depositor wallet**, reused +across all users' flows. On-chain you see only `shared-depositor -> stealth_N`; the +user-to-recipient map lives in your own off-chain records. **Do not use a per-user +depositor** — it would link each user's deposit and withdrawal on-chain. + +## Honest privacy model +- **Commingling / decorrelation, not a cryptographic graph-break.** The depositor + signature links the shared depositor to each payout on-chain. Unlinkability comes + from many users sharing the depositor plus batching/jitter — not zero-knowledge. +- **Amounts are visible.** The Pedersen commitment is recorded for + disclosure/audit; the lamport delta is on-chain (TIER_1 in the SDK's privacy-tier + model). Use the SDK's `assessFlowPrivacy` to score a flow honestly. +- **Rent-exempt guard.** A one-time stealth recipient is a plain system account; + `buildPrivateSendSolTx` rejects a payout that would leave it below the + rent-exempt minimum. Pre-fund the stealth (or fund it on the funding leg). + +## Run the tests +```bash +pnpm --filter @sipher/sdk build # the example imports the built SDK +pnpm --filter @sipher/example-vault-privacy-provider test +``` + +## SPL / Token-2022 extension +The vault also supports classic SPL and Token-2022. The analogous path swaps the +`*Sol*` builders for `buildDepositTx` / `buildPrivateSendTx` with a `mint` + token +program and derives the stealth recipient's associated token account — otherwise +the shape is identical. diff --git a/examples/vault-privacy-provider/src/index.ts b/examples/vault-privacy-provider/src/index.ts new file mode 100644 index 0000000..f1a1ffb --- /dev/null +++ b/examples/vault-privacy-provider/src/index.ts @@ -0,0 +1,7 @@ +export { SipherVaultPrivacyProvider } from './provider.js' +export { parseStealthMetaAddress, assembleWithdrawArtifacts } from './stealth.js' +export { hexToBytes, bigintToLeBytes } from './hex.js' +export type { + VaultPrivacyProvider, StealthMetaAddress, WithdrawArtifacts, + DepositResult, PrivateWithdrawResult, RefundResult, +} from './types.js' diff --git a/examples/vault-privacy-provider/test/provider.test.ts b/examples/vault-privacy-provider/test/provider.test.ts index a81c1ce..8950aab 100644 --- a/examples/vault-privacy-provider/test/provider.test.ts +++ b/examples/vault-privacy-provider/test/provider.test.ts @@ -141,3 +141,12 @@ describe('SipherVaultPrivacyProvider — privateWithdraw', () => { .rejects.toThrow('rent-exempt minimum') }) }) + +describe('barrel', () => { + it('re-exports the public surface', async () => { + const m = await import('../src/index.js') + expect(typeof m.SipherVaultPrivacyProvider).toBe('function') + expect(typeof m.assembleWithdrawArtifacts).toBe('function') + expect(typeof m.parseStealthMetaAddress).toBe('function') + }) +}) From 20fbfb82f923aacdbdff9a2d0579ce0df1a721e8 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 25 Jun 2026 15:52:08 +0700 Subject: [PATCH 8/8] fix(example): pass bigint to funding transfer; reconcile spec feeBps row --- .../specs/2026-06-25-vault-privacy-provider-example-design.md | 2 +- examples/vault-privacy-provider/src/provider.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md b/docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md index 8afef44..4abfb0b 100644 --- a/docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md +++ b/docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md @@ -103,7 +103,7 @@ input to one-time stealth derivation). | Method | Backed by (`@sipher/sdk`) | |---|---| -| `feeBps` | read from the vault config (`getVaultConfig`), default 10 bps | +| `feeBps` | advertised/preview rate from the constructor (`opts.feeBps ?? DEFAULT_FEE_BPS`); the actual fee deducted is the on-chain-derived `feeAmount` returned by `buildPrivateSendSolTx` (surfaced as `PrivateWithdrawResult.feeLamports`) | | `buildFundingTx` | `SystemProgram.transfer` (no SDK call) | | `verifyFunding` | `connection.getTransaction` + lamport-delta check | | `deposit` | `buildDepositSolTx(conn, depositorKp.publicKey, lamports)` → sign → send | diff --git a/examples/vault-privacy-provider/src/provider.ts b/examples/vault-privacy-provider/src/provider.ts index f78cdb1..ee7d231 100644 --- a/examples/vault-privacy-provider/src/provider.ts +++ b/examples/vault-privacy-provider/src/provider.ts @@ -40,7 +40,7 @@ export class SipherVaultPrivacyProvider implements VaultPrivacyProvider { tx.add(SystemProgram.transfer({ fromPubkey: new PublicKey(args.fromPk), toPubkey: new PublicKey(args.depositorPk), - lamports: Number(args.amountLamports), + lamports: args.amountLamports, })) return tx }