Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
866 changes: 866 additions & 0 deletions docs/superpowers/plans/2026-06-25-vault-privacy-provider-example.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<Transaction>

/** Confirm the funding transfer landed and credited the expected lamports. */
verifyFunding(args: {
depositorPk: string
expectedLamports: bigint
txSignature: string
}): Promise<void>

/** 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` | 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 |
| `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.
43 changes: 43 additions & 0 deletions examples/vault-privacy-provider/README.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions examples/vault-privacy-provider/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
21 changes: 21 additions & 0 deletions examples/vault-privacy-provider/src/hex.ts
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions examples/vault-privacy-provider/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading