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
2 changes: 1 addition & 1 deletion app/src/views/DepositView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ interface DepositTxResponse {
depositRecordAddress?: string
vaultTokenAddress?: string
amountBaseUnits?: string
feeBps?: number
feeTenthsBps?: number
network?: string
}

Expand Down
349 changes: 349 additions & 0 deletions docs/superpowers/plans/2026-07-02-sipher-sdk-vault-fee-tenths-bps.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ native-SOL builders shipped in `@sipher/sdk` (`buildDepositSolTx`,

```ts
export interface VaultPrivacyProvider {
/** Protocol fee charged on a private withdrawal, in basis points. */
readonly feeBps: number
/** Protocol fee charged on a private withdrawal, in tenths of a basis point. */
readonly feeTenthsBps: number

/**
* Build the unsigned funding transfer: the user's main wallet sends lamports
Expand Down Expand Up @@ -103,18 +103,23 @@ input to one-time stealth derivation).

| 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`) |
| `feeTenthsBps` | advertised/preview rate from the constructor (`opts.feeTenthsBps ?? DEFAULT_FEE_TENTHS_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` |
| `previewWithdraw` | `fee = gross * feeTenthsBps / 100_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.

The vault's native unit is tenths of a basis point (`feeTenthsBps`), one order of
magnitude finer than the whole-bps unit many integrator-side fee interfaces use. An
integrator whose interface uses whole-bps `feeBps` receives `feeTenthsBps / 10` at
the downstream port — the sole conversion, kept out of this repo.

## 5. Depositor-as-vault model (load-bearing)

The vault's `withdraw_private_sol` requires the **depositor** as signer
Expand Down Expand Up @@ -175,7 +180,7 @@ relayer/funding leg to do so) and lets the builder's actionable error propagate.
## 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`
- `feeTenthsBps` reflects the **withdraw-only** fee semantics: `deposit` and `refund`
move the full amount; only `privateWithdraw` deducts the fee. `previewWithdraw`
mirrors the on-chain computation.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# @sipher/sdk vault-fee unit migration — whole-bps → tenths-of-bps

**Date:** 2026-07-02
**Branch:** `fix/vault-fee-tenths-bps` (off `origin/main` @ `b48e8ed`)
**Status:** design — awaiting review before writing the implementation plan.

---

## 1. Context & problem

The on-chain `sipher_vault` program re-precisioned its fee field from whole basis
points to **tenths of a basis point** (sip-protocol #1213, deployed to devnet
2026-06-30):

- Field renamed `fee_bps` → `fee_tenths_bps` (`u16`, same account offset **40** =
8 disc + 32 authority).
- Divisor changed `10_000` → **`100_000`** (`FEE_TENTHS_BPS_DENOMINATOR`).
- Cap `MAX_FEE_TENTHS_BPS = 1000` (still 1%).
- Live devnet config now stores **`75` = 7.5 bps = 0.075%**.

`@sipher/sdk` (and its two in-repo consumers) still speak the **old whole-bps model**.
Left unchanged, the SDK decodes the raw `75` and computes `75 / 10_000 = 0.75%` — a
**10× fee over-report**. This corrupts every returned fee/net figure and, in the
native-SOL path, can trip the rent-exempt guard into false rejections.

This SDK is the packaging gate for a downstream privacy-provider integration, so
its reported fees must be exact. This migration makes the SDK report the fee
correctly and is the prerequisite for publishing the package.

## 2. Goal & non-goals

**Goal:** Represent the vault fee as **integer `feeTenthsBps`** everywhere in the
`sipher` repo, mirroring the on-chain source of truth, with all fee arithmetic in
exact integer `BigInt` math (`amount * feeTenthsBps / 100_000n`). No fractional
"bps" anywhere in our code — the fractional 7.5 only ever materialises at the true
downstream integrator boundary (outside this repo).

**Non-goals (hard guard-rails):**

- **`sip_privacy` stays whole-bps forever.** It is a *separate* program (`fee_bps`,
÷`10_000`). This includes the *skipped-byte* `fee_bps(2)` layout comments inside
`privacy.ts` / `privacy-sol.ts` that describe the `sip_privacy` Config while
seeking to `total_transfers` — those are **not** renamed.
- **App static per-chain fee list** (`packages/agent/src/routes/chains.ts`,
`feeBps: 50/10/…`) is hardcoded display data for other chains, unrelated to the
vault config; the app never decodes the vault fee. Untouched.
- **npm publish** is a separate, explicit-go action after this PR merges (§9).
- **The downstream backend port** is out of scope (gated on packaging + the
integrator's seam).

## 3. Key decision — representation (approved)

Mirror the chain: rename the SDK's public `feeBps` → **`feeTenthsBps: number`**
(integer, e.g. `75`). Rationale:

- All fee math stays exact integer `BigInt` — permanently removes the `BigInt(7.5)`
landmine that a fractional-`feeBps` approach would introduce.
- Single unit end-to-end; the field name documents its own unit.
- Matches the sip-protocol repo convention (already standardised on `feeTenthsBps`).
- `@sipher/sdk` is unpublished (npm 404), so there is **no external API break** —
every consumer is in this monorepo and updated in the same branch.

Human-readable display everywhere derives a percent: `feeTenthsBps / 1000`
(`75 / 1000 = 0.075%`).

The agent's **outbound** response field is renamed to `feeTenthsBps` too (approved —
full consistency), keeping `feePercent` as the human number.

## 4. Blast-radius map (three packages + tests + docs)

### 4.1 `@sipher/sdk` (`packages/sdk/`)

| File | Change |
|---|---|
| `src/types.ts` | `VaultConfig.feeBps` → `feeTenthsBps: number` |
| `src/config.ts` | `DEFAULT_FEE_BPS = 10` → `DEFAULT_FEE_TENTHS_BPS = 100` (value-preserving: 0.1% in tenths, **not** 75); `MAX_FEE_BPS = 100` → `MAX_FEE_TENTHS_BPS = 1000` (value-preserving: 1%); layout comment `fee_bps(2)` → `fee_tenths_bps(2)` |
| `src/vault.ts` | `deserializeVaultConfig`: decode field → `feeTenthsBps` (offset 40, still `readUInt16LE`); update the layout doc comment |
| `src/privacy.ts` | vault-fee fallback → `DEFAULT_FEE_TENTHS_BPS`; decode → `feeTenthsBps`; fee math `/ 10_000n` → `/ 100_000n`; vault-fee comment. **Leave the `sip_privacy` layout comment (`total_transfers` seek) as `fee_bps`.** |
| `src/privacy-sol.ts` | Same as `privacy.ts` (fallback, decode, `/100_000n` math, comment; leave sip_privacy comment) |
| `src/idl/sipher_vault.json` | Rename structural sites (`initialize` arg, `update_fee` arg, `FeeUpdated` event old/new, `VaultConfig` field) + doc lines (§6) |
| `scripts/recon-devnet-vault-tokens.mjs` | Decode + display → tenths |
| `package.json` | `0.1.0` → `0.2.0` |
| `tests/vault.test.ts` | Constant + builder + decode assertions → tenths |
| `tests/privacy-sol.test.ts` (+ `privacy.test.ts` if present) | Fee-math assertions → `/100_000n` |

### 4.2 `@sipher/agent` (`packages/agent/`)

| File | Change |
|---|---|
| `src/tools/status.ts` | Fallback; `feePercent = feeTenthsBps / 1000`; surfaced field → `feeTenthsBps` |
| `src/tools/send.ts` | Fallback; percent; surfaced field (and the fee-override *input* field, if present, → `feeTenthsBps`) |
| `src/routes/vault-deposit-tx.ts` | `feeTenthsBps: DEFAULT_FEE_TENTHS_BPS` |
| `tests/tools/status.test.ts`, `tests/tools/send.test.ts`, `tests/tools.test.ts`, `tests/routes/vault-deposit-tx.test.ts` | Fee assertions → tenths |

### 4.3 Reference adapter (`examples/vault-privacy-provider/`)

This package is on `origin/main` (merged #354) and **breaks on the constant rename**
(`provider.ts:5` imports `DEFAULT_FEE_BPS`), so it must migrate in the same branch to
keep the build green. It also carries the same 10× bug (`provider.ts:82` `/10_000n`).

| File | Change |
|---|---|
| `src/types.ts` | Interface `feeBps` → `feeTenthsBps` |
| `src/provider.ts` | Import `DEFAULT_FEE_TENTHS_BPS`; field + constructor opt → `feeTenthsBps`; `previewWithdraw`: `gross * BigInt(this.feeTenthsBps) / 100_000n` (exact; sync signature preserved) |
| `test/provider.test.ts` | Config-buffer write + `feeTenthsBps` assertions (default `100`; explicit `75` case for the 7.5 bps math) |
| `docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md` | Update the generic fee row to `feeTenthsBps` + note the single ÷10 conversion to the integrator's `feeBps` happens *downstream* (naming-clean) |

**Adapter-boundary treatment (recommended, confirm at review):** the adapter keeps
the *consistent* `feeTenthsBps` unit like the rest of the repo. The downstream port
maps `feeTenthsBps (75)` → the integrator's `feeBps (7.5)` with a single documented
÷10 at the real seam — keeping *our* repo free of any fractional-bps / `BigInt(7.5)`
edge. (Alternative: keep the example's field as fractional `feeBps` for a 1:1
field-name port — rejected to avoid reintroducing the fractional edge here.)

## 5. IDL regeneration approach

The bundled IDL (`packages/sdk/src/idl/sipher_vault.json`) is **vendored but not
imported at runtime** (decode is a hand-rolled offset reader), so this update is for
correctness/consistency, not behaviour.

1. **Preferred:** `anchor build` in `sip-protocol/programs/sipher-vault` to regenerate
`target/idl/sipher_vault.json`, then copy the field-relevant result across.
2. **Fallback (likely needed):** the host rustc (1.94) removed
`proc_macro2::Span::source_file`, which currently breaks Anchor's IDL generator
(flagged in `tests/sipher-vault/02-deposit.test.ts`). If so, **surgically rename**
the field in the committed JSON. The change is a pure name rename (`u16`→`u16`,
offset unchanged), verified line-by-line against the Rust source — low-risk, and
the IDL is unused at runtime regardless.

## 6. IDL rename sites (`sipher_vault.json`)

Structural (`fee_bps` → `fee_tenths_bps`, `new_fee_bps` → `new_fee_tenths_bps`,
`old_fee_bps` → `old_fee_tenths_bps`):

- `initialize` instruction arg
- `update_fee` instruction arg
- `FeeUpdated` event `old_*` / `new_*` fields
- `VaultConfig` account struct field

Doc-comment lines mentioning `fee = amount · fee_bps / 10_000` → `… fee_tenths_bps /
100_000`.

## 7. Testing strategy (TDD — failing first)

- **Regression guard (the 10× fix):** withdraw `1_000_000` at `feeTenthsBps = 75` →
`feeAmount = 750`, `netAmount = 999_250` (old whole-bps code yields `7_500`). Add
to the vault / privacy-sol fee tests and the example's `previewWithdraw`.
- **Constants:** `DEFAULT_FEE_TENTHS_BPS === 100` (value-preserving 0.1%), `MAX_FEE_TENTHS_BPS === 1000`.
- **Decode round-trip:** a config buffer with `writeUInt16LE(75, 40)` deserialises to
`feeTenthsBps === 75`.
- **Display:** `feeTenthsBps / 1000 === 0.075` percent.
- Order: write/flip the tenths-bps assertions, watch them fail against current code,
then apply the rename + divisor fix.

Run per package: `pnpm --filter @sipher/sdk test`, agent tests, and the example's
`vitest`. Note: sipher has pre-existing flakes (rate-limiter / CORS / viewing-key)
that pass in isolation — re-run rather than treat as regressions.

## 8. Version

Bump `@sipher/sdk` `0.1.0` → **`0.2.0`** — first published version, and the fee
semantics change warrants a minor. (Alternative: publish first as `0.1.0`; rejected —
the change is worth signalling.)

## 9. Publish gating

Land the **reviewed** PR this session (RECTOR reviews/merges — never self-merged).
npm publish is a **separate, explicit-go** step after merge: verify the npm
token/scope, `pnpm --filter @sipher/sdk build`, then `publish`. Publishing is outward
and hard to reverse — it does not happen without an explicit go.

## 10. Decision points to confirm at review

1. **Adapter boundary** — `feeTenthsBps` (consistent; ÷10 downstream) vs a fractional
`feeBps` for 1:1 port parity. Recommended: `feeTenthsBps`.
2. **Version** — `0.2.0` (recommended) vs `0.1.0` first-publish.
3. **Branch name** — `fix/vault-fee-tenths-bps` (it is a correctness fix + unit
adaptation).

## 11. Process

Full superpowers cycle: this spec → writing-plans → TDD → `/code-review` (per-logical-
task + whole-branch). GPG-signed commits (`BF47B9DC1FA320FA`), **no AI attribution**,
one commit per logical fix, **no self-merge**. Naming-gate grep must return empty over
any public artifact before each commit.
11 changes: 6 additions & 5 deletions examples/vault-privacy-provider/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import {
Connection, Keypair, PublicKey, SystemProgram, Transaction,
} from '@solana/web3.js'
import {
buildDepositSolTx, buildRefundSolTx, buildPrivateSendSolTx, DEFAULT_FEE_BPS,
buildDepositSolTx, buildRefundSolTx, buildPrivateSendSolTx, DEFAULT_FEE_TENTHS_BPS,
FEE_TENTHS_BPS_DENOMINATOR,
} from '@sipher/sdk'
import { assembleWithdrawArtifacts } from './stealth.js'
import type {
Expand All @@ -18,10 +19,10 @@ import type {
* + batching/jitter — supply ONE shared depositor keypair to every call.
*/
export class SipherVaultPrivacyProvider implements VaultPrivacyProvider {
readonly feeBps: number
readonly feeTenthsBps: number

constructor(private readonly connection: Connection, opts: { feeBps?: number } = {}) {
this.feeBps = opts.feeBps ?? DEFAULT_FEE_BPS
constructor(private readonly connection: Connection, opts: { feeTenthsBps?: number } = {}) {
this.feeTenthsBps = opts.feeTenthsBps ?? DEFAULT_FEE_TENTHS_BPS
}

private async signAndSubmit(tx: Transaction, signer: Keypair): Promise<string> {
Expand Down Expand Up @@ -79,7 +80,7 @@ export class SipherVaultPrivacyProvider implements VaultPrivacyProvider {
}

previewWithdraw(grossLamports: bigint): { feeLamports: bigint; netLamports: bigint } {
const feeLamports = (grossLamports * BigInt(this.feeBps)) / 10_000n
const feeLamports = (grossLamports * BigInt(this.feeTenthsBps)) / FEE_TENTHS_BPS_DENOMINATOR
return { feeLamports, netLamports: grossLamports - feeLamports }
}

Expand Down
4 changes: 2 additions & 2 deletions examples/vault-privacy-provider/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export interface RefundResult { txSignature: string; refundedLamports: bigint }
* 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
/** Advertised withdraw fee (tenths of a bps). The actual deducted fee comes from on-chain config. */
readonly feeTenthsBps: number
buildFundingTx(args: {
fromPk: string; depositorPk: string; amountLamports: bigint; recentBlockhash: string
}): Promise<Transaction>
Expand Down
21 changes: 13 additions & 8 deletions examples/vault-privacy-provider/test/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import { parseStealthMetaAddress } from '../src/stealth.js'
const BLOCKHASH = 'GfVcyD4kkTrj4bKc7WA9sZCin9JDbdT458zqL4zjxx2v'
const DEPOSITOR_KP = Keypair.generate()

// Mock Connection: dispatches getAccountInfo by pubkey (config carries fee_bps at
// Mock Connection: dispatches getAccountInfo by pubkey (config carries fee_tenths_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
function mockConn(opts: { feeTenthsBps?: number; recordBalance?: bigint } = {}): Connection {
const { feeTenthsBps = 100, 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)
// disc(8) + authority(32) + fee_tenths_bps(u16 at offset 40) + ...
const configBuf = Buffer.alloc(68); configBuf.writeUInt16LE(feeTenthsBps, 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.
Expand All @@ -40,12 +40,17 @@ function mockConn(opts: { feeBps?: number; recordBalance?: bigint } = {}): Conne
}

describe('SipherVaultPrivacyProvider — funding/verify/deposit/refund/preview', () => {
it('feeBps defaults to the vault default and previewWithdraw splits fee/net', () => {
it('feeTenthsBps defaults to the vault default and previewWithdraw splits fee/net', () => {
const p = new SipherVaultPrivacyProvider(mockConn())
expect(p.feeBps).toBe(10)
expect(p.feeTenthsBps).toBe(100)
expect(p.previewWithdraw(2_000_000n)).toEqual({ feeLamports: 2_000n, netLamports: 1_998_000n })
})

it('previewWithdraw uses tenths-bps precision (75 → 0.075%)', () => {
const p = new SipherVaultPrivacyProvider(mockConn(), { feeTenthsBps: 75 })
expect(p.previewWithdraw(2_000_000n)).toEqual({ feeLamports: 1_500n, netLamports: 1_998_500n })
})

it('buildFundingTx is a plain SystemProgram.transfer to the depositor wallet', async () => {
const p = new SipherVaultPrivacyProvider(mockConn())
const from = Keypair.generate().publicKey.toBase58()
Expand Down Expand Up @@ -86,7 +91,7 @@ const RECIPIENT = parseStealthMetaAddress(`sip:solana:0x${VALID_SPENDING}:0x${VA

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 p = new SipherVaultPrivacyProvider(mockConn({ feeTenthsBps: 100 }))
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)
Expand Down
4 changes: 2 additions & 2 deletions packages/agent/src/routes/vault-deposit-tx.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Router, type Request, type Response } from 'express'
import { executeDeposit } from '../tools/deposit.js'
import { loadNetworkConfig } from '../config/network.js'
import { DEFAULT_FEE_BPS } from '@sipher/sdk'
import { DEFAULT_FEE_TENTHS_BPS } from '@sipher/sdk'

export const vaultDepositTxRouter = Router()

Expand Down Expand Up @@ -70,7 +70,7 @@ vaultDepositTxRouter.post('/deposit-tx', async (req: Request, res: Response) =>
depositRecordAddress: result.details.depositRecordAddress,
vaultTokenAddress: result.details.vaultTokenAddress,
amountBaseUnits: result.details.amountBaseUnits,
feeBps: DEFAULT_FEE_BPS,
feeTenthsBps: DEFAULT_FEE_TENTHS_BPS,
network,
})
} catch (err) {
Expand Down
Loading
Loading