diff --git a/app/src/views/DepositView.tsx b/app/src/views/DepositView.tsx index cd730b2..4d401c1 100644 --- a/app/src/views/DepositView.tsx +++ b/app/src/views/DepositView.tsx @@ -48,7 +48,7 @@ interface DepositTxResponse { depositRecordAddress?: string vaultTokenAddress?: string amountBaseUnits?: string - feeBps?: number + feeTenthsBps?: number network?: string } diff --git a/docs/superpowers/plans/2026-07-02-sipher-sdk-vault-fee-tenths-bps.md b/docs/superpowers/plans/2026-07-02-sipher-sdk-vault-fee-tenths-bps.md new file mode 100644 index 0000000..f828e95 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-sipher-sdk-vault-fee-tenths-bps.md @@ -0,0 +1,349 @@ +# @sipher/sdk vault-fee tenths-bps migration — 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:** Migrate `@sipher/sdk` (and its two in-repo consumers) from whole-basis-point vault fees to integer tenths-of-a-bps, mirroring the re-precisioned on-chain program, and fix the resulting 10× fee mis-report. + +**Architecture:** The on-chain `VaultConfig.fee_tenths_bps` (u16, offset 40, ÷`100_000`) is decoded by a hand-rolled offset reader in three SDK sites (`vault.ts`, `privacy.ts`, `privacy-sol.ts`) and surfaced through the agent + a reference adapter. We rename the field `feeBps → feeTenthsBps` end-to-end, change every vault fee divisor `10_000n → 100_000n`, and preserve values by scaling constants/fixtures ×10 (0.1% stays 0.1%). The overloaded token `feeBps` also names an unrelated static per-chain list (`chains.ts`) and `sip_privacy` layout comments — both stay whole-bps and must NOT be renamed. + +**Tech Stack:** TypeScript (ESM, `.js` import suffixes), Vitest, pnpm workspaces, `@solana/web3.js`, Anchor IDL JSON. + +## Global Constraints + +- **`sip_privacy` stays whole-bps forever** — never rename `fee_bps` in the `sip_privacy` layout comments inside `privacy.ts` / `privacy-sol.ts` (the `total_transfers` seek), nor touch `sip_privacy`'s `/ 10_000n`. +- **`packages/agent/src/routes/chains.ts` is OUT OF SCOPE** — its 13 `feeBps` entries are static per-chain display fees (whole bps), a different concept from the vault fee. Do not rename. +- **Value-preserving unit conversion:** `DEFAULT_FEE_BPS = 10` → `DEFAULT_FEE_TENTHS_BPS = 100` (0.1%); `MAX_FEE_BPS = 100` → `MAX_FEE_TENTHS_BPS = 1000` (1%). Every migrated fixture value scales ×10. +- **Divisor:** vault fee is `amount * feeTenthsBps / 100_000n` (exact integer BigInt). Never `BigInt()`. +- **Percent display:** `feeTenthsBps / 1000` (e.g. `100 → 0.1%`, `75 → 0.075%`). +- **2-space indent, no semicolons.** GPG-signed commits (`BF47B9DC1FA320FA`), **no AI attribution**, one commit per logical fix, **no self-merge**. +- **Naming gate** (before any commit touching `examples/` or docs): the partner/competitor deny-list grep over the changed paths must return empty. The deny-list pattern is **supplied out-of-band** (executor's prompt / private addendum) — never write the pattern itself into this repo. +- **Worktree:** all work in `/Users/rector/local-dev/sipher-wt/vault-fee-tenths-bps` (branch `fix/vault-fee-tenths-bps`, off `origin/main` @ `b48e8ed`). +- **Test flakes:** sipher has pre-existing rate-limiter / CORS / viewing-key flakes that pass in isolation — re-run rather than treat as regressions. + +--- + +## Task 1: `@sipher/sdk` core — types, constants, decode, fee math + +**Files:** +- Modify: `packages/sdk/src/types.ts:9` +- Modify: `packages/sdk/src/config.ts:48-50,55` +- Modify: `packages/sdk/src/vault.ts:105-156` (`deserializeVaultConfig` + doc comment) +- Modify: `packages/sdk/src/privacy.ts:142-146,165` (vault-fee fallback/decode/math; NOT the sip_privacy comment at :149) +- Modify: `packages/sdk/src/privacy-sol.ts:111-115,132` (same; NOT the sip_privacy comment at :119) +- Modify: `packages/sdk/src/index.ts` (2 re-exports of the renamed constants) +- Test: `packages/sdk/tests/vault.test.ts` (14 sites), `packages/sdk/tests/privacy-sol.test.ts` (5 sites), `packages/sdk/tests/privacy.test.ts` (add SPL fee guard) + +**Interfaces:** +- Produces: `VaultConfig.feeTenthsBps: number`; consts `DEFAULT_FEE_TENTHS_BPS = 100`, `MAX_FEE_TENTHS_BPS = 1000`; `deserializeVaultConfig` returns `{ …, feeTenthsBps }`; `buildPrivateSendTx` / `buildPrivateSendSolTx` compute fee via `/ 100_000n`. Every later task consumes these names. + +- [ ] **Step 1: Flip the constant + decode assertions to the new unit (failing tests)** + +In `packages/sdk/tests/vault.test.ts`: +- `:68-69` → +```ts + expect(DEFAULT_FEE_TENTHS_BPS).toBe(100) + expect(MAX_FEE_TENTHS_BPS).toBe(1000) +``` +- Builder default `:259` `feeBps = 10,` → `feeTenthsBps = 100,`; comment `:279` `// fee_bps: u16 LE` → `// fee_tenths_bps: u16 LE`; `:280` `buf.writeUInt16LE(feeBps, offset)` → `buf.writeUInt16LE(feeTenthsBps, offset)` (and the `overrides` param name). +- `:310` `expect(config.feeBps).toBe(10)` → `expect(config.feeTenthsBps).toBe(100)`. +- `:324-327` "handles max fee BPS" → rename test to "handles max fee tenths-bps"; `{ feeBps: 100 }` → `{ feeTenthsBps: 1000 }`; `expect(config.feeBps).toBe(100)` → `expect(config.feeTenthsBps).toBe(1000)`. +- `:353` `expect(config.feeBps).toBe(10)` → `expect(config.feeTenthsBps).toBe(100)`. + +Update the imports at the top of the file: `DEFAULT_FEE_BPS`/`MAX_FEE_BPS` → `DEFAULT_FEE_TENTHS_BPS`/`MAX_FEE_TENTHS_BPS`. + +- [ ] **Step 2: Add the 10× regression guard for the SOL fee path (failing test)** + +In `packages/sdk/tests/privacy-sol.test.ts`, update `mockConn` (rename `feeBps` opt → `feeTenthsBps`, default `100`, comment `fee_bps` → `fee_tenths_bps`, `:34` `writeUInt16LE(feeTenthsBps, 40)`), then add: +```ts + it('computes the fee at tenths-bps precision (÷100_000, not ÷10_000)', async () => { + // 7.5 bps = 75 tenths on 2_000_000 → 1_500 (old whole-bps code gives 15_000) + const res = await buildPrivateSendSolTx({ + ...baseParams, + connection: mockConn({ feeTenthsBps: 75, stealthLamports: 1_000_000_000 }), + }) + expect(res.feeAmount).toBe(1_500n) + expect(res.netAmount).toBe(1_998_500n) + }) +``` +(If a `feeAmount`/`netAmount` assertion already exists elsewhere in the file with `feeBps`, migrate it: rename the mock opt and scale expectations to the `/100_000n` result.) + +- [ ] **Step 3: Add the same guard for the SPL path (currently untested)** + +`packages/sdk/tests/privacy.test.ts` has no fee assertion today. Add one mirroring Step 2 but for `buildPrivateSendTx` (SPL). Use the file's existing mock/connection pattern; if it lacks a config mock, add one that writes `writeUInt16LE(75, 40)`. Assert `res.feeAmount === amount * 75n / 100_000n` and `res.netAmount === amount - res.feeAmount` for a chosen `amount` (e.g. `2_000_000n → fee 1_500n`). + +- [ ] **Step 4: Run the SDK tests — verify they FAIL** + +Run: `cd /Users/rector/local-dev/sipher-wt/vault-fee-tenths-bps && pnpm --filter @sipher/sdk test -- --run` +Expected: FAIL — unresolved `DEFAULT_FEE_TENTHS_BPS`/`feeTenthsBps`, and the ÷100_000 guards report `15_000n`/`1_500n` mismatches against the current `/10_000n` code. + +- [ ] **Step 5: Rename constants + type** + +`packages/sdk/src/config.ts`: +```ts +/** 0.10% fee (100 tenths-of-a-bps) */ +export const DEFAULT_FEE_TENTHS_BPS = 100 +/** Max 1% fee (1000 tenths-of-a-bps) */ +export const MAX_FEE_TENTHS_BPS = 1000 +``` +And the layout comment `:55` `fee_bps(2)` → `fee_tenths_bps(2)`. + +`packages/sdk/src/types.ts:9` `feeBps: number` → `feeTenthsBps: number`. + +`packages/sdk/src/index.ts`: rename the two re-exported constant names. + +- [ ] **Step 6: Rename the decode** + +`packages/sdk/src/vault.ts` — in `deserializeVaultConfig`, the doc comment `:109` `fee_bps: u16` → `fee_tenths_bps: u16`; `:130` `const feeBps = data.readUInt16LE(offset)` → `const feeTenthsBps = data.readUInt16LE(offset)`; `:149` return `feeBps` → `feeTenthsBps`. + +- [ ] **Step 7: Fix the two vault-fee math sites** + +`packages/sdk/src/privacy.ts`: +- `:142` `let feeBps = 10 // fallback to default` → `let feeTenthsBps = DEFAULT_FEE_TENTHS_BPS // fallback to default` (import `DEFAULT_FEE_TENTHS_BPS` from `./config.js`). +- `:144` comment `// fee_bps is at offset …` → `// fee_tenths_bps is at offset …`; `:145` `feeBps = configInfo.data.readUInt16LE(40)` → `feeTenthsBps = …`. +- `:165` `const feeAmount = (amount * BigInt(feeBps)) / 10_000n` → `const feeAmount = (amount * BigInt(feeTenthsBps)) / 100_000n`. +- **Leave `:149` unchanged** — it documents the `sip_privacy` Config layout (`authority(32) + fee_bps(2) + paused(1) + total_transfers`) while seeking `total_transfers`; that `fee_bps` is `sip_privacy`'s and stays. + +`packages/sdk/src/privacy-sol.ts`: identical edits at `:111` (fallback), `:113` (comment), `:114` (decode), `:132` (math). **Leave `:119`** (sip_privacy layout comment). + +- [ ] **Step 8: Run the SDK tests — verify they PASS** + +Run: `pnpm --filter @sipher/sdk test -- --run` +Expected: PASS (all vault/privacy/privacy-sol suites green). + +- [ ] **Step 9: Typecheck the SDK** + +Run: `pnpm --filter @sipher/sdk typecheck` (or `pnpm --filter @sipher/sdk exec tsc --noEmit`) +Expected: no errors. + +- [ ] **Step 10: Commit** + +```bash +cd /Users/rector/local-dev/sipher-wt/vault-fee-tenths-bps +git add packages/sdk/src packages/sdk/tests +git commit -S -m "fix(sdk): vault fee → tenths-bps (feeTenthsBps, ÷100_000); fixes 10× report" +``` + +--- + +## Task 2: `@sipher/sdk` — IDL sync, recon scripts, version + +**Files:** +- Modify: `packages/sdk/src/idl/sipher_vault.json` (8 sites) +- Modify: `packages/sdk/scripts/recon-devnet-vault-tokens.mjs:19`, `packages/sdk/scripts/devnet-check.ts:38` +- Modify: `packages/sdk/package.json` (version) + +- [ ] **Step 1: Regenerate or hand-edit the IDL** + +Preferred (if the host toolchain cooperates): in the OTHER repo, +`cd /Users/rector/local-dev/sip-protocol/programs/sipher-vault && anchor build` (or `anchor idl build`), then copy the field-relevant result. **This host's rustc (1.94) likely breaks Anchor IDL-gen** (removed `proc_macro2::Span::source_file`) — if it fails, hand-edit the committed JSON. The IDL is vendored-but-unused at runtime, and the change is a pure name rename (`u16`→`u16`, offset unchanged), so a verified hand-edit is sound. + +Hand-edit `packages/sdk/src/idl/sipher_vault.json` — rename these 8 sites (values already located): +| Line | From | To | +|---|---|---| +| 458 | `…during withdraw_private_sol (fee_bps` | `…(fee_tenths_bps` (doc) | +| 1144 | `"name": "fee_bps"` (initialize arg) | `"name": "fee_tenths_bps"` | +| 1677 | `"name": "new_fee_bps"` (update_fee arg) | `"name": "new_fee_tenths_bps"` | +| 1973 | ``fee = amount · fee_bps / 10_000`` (doc) | ``fee = amount · fee_tenths_bps / 100_000`` | +| 2580 | `"name": "old_fee_bps"` (FeeUpdated event) | `"name": "old_fee_tenths_bps"` | +| 2584 | `"name": "new_fee_bps"` (FeeUpdated event) | `"name": "new_fee_tenths_bps"` | +| 2628 | `"name": "fee_bps"` (VaultConfig field) | `"name": "fee_tenths_bps"` | + +Verify against source: `grep -n fee_tenths_bps /Users/rector/local-dev/sip-protocol/programs/sipher-vault/programs/sipher-vault/src/state.rs` (must show the u16 field at the config struct). + +- [ ] **Step 2: Fix the recon/devnet scripts** + +`packages/sdk/scripts/recon-devnet-vault-tokens.mjs:19` `const feeBps = d.readUInt16LE(off)` → `const feeTenthsBps = d.readUInt16LE(off)`; update any display line to divide by `10` for bps (or `1000` for percent). `packages/sdk/scripts/devnet-check.ts:38` `config.feeBps` → `config.feeTenthsBps` (display). + +- [ ] **Step 3: Confirm no stray `fee_bps` remains in the IDL** + +Run: `grep -nE '"name": *"(new_|old_)?fee_bps"' packages/sdk/src/idl/sipher_vault.json` +Expected: no output. + +- [ ] **Step 4: Bump the version** + +`packages/sdk/package.json` `"version": "0.1.0"` → `"version": "0.2.0"`. + +- [ ] **Step 5: Commit (two logical commits)** + +```bash +git add packages/sdk/src/idl/sipher_vault.json packages/sdk/scripts +git commit -S -m "fix(sdk): sync vault IDL + recon scripts to fee_tenths_bps" +git add packages/sdk/package.json +git commit -S -m "chore(sdk): bump @sipher/sdk to 0.2.0" +``` + +--- + +## Task 3: `@sipher/agent` — consumers + tests + +**Files:** +- Modify: `packages/agent/src/tools/status.ts:61-62,73,89` +- Modify: `packages/agent/src/tools/send.ts:107-110,128,270` +- Modify: `packages/agent/src/routes/vault-deposit-tx.ts:73` +- Test: `status.test.ts` (2), `send.test.ts` (3), `tools.test.ts` (2), `routes/vault-deposit-tx.test.ts` (1), `fixtures/user-tool-mocks.ts` (2), and the send-output `privacy` mocks in `send-private-to-sns.test.ts` (1), `agent-signing-wrapper.test.ts` (3), `agent-display-formatter.test.ts` (2), `integration/signing-callback-roundtrip.test.ts` (2) +- **DO NOT TOUCH:** `packages/agent/src/routes/chains.ts` (per-chain static list) + +**Interfaces:** +- Consumes: `VaultConfig.feeTenthsBps`, `DEFAULT_FEE_TENTHS_BPS` from `@sipher/sdk` (Task 1). +- Produces: agent responses surface `feeTenthsBps` (integer) + `feePercent` (unchanged strings). + +- [ ] **Step 1: Flip the agent-test assertions/fixtures (failing tests)** + +Every agent-test `feeBps` hit is vault-fee-related → rename key to `feeTenthsBps`, scale value ×10. `feePercent` string assertions stay identical (invariant under ×10). +- `status.test.ts:49` `feeBps).toBe(10)` → `feeTenthsBps).toBe(100)`; `:50` `feePercent).toBe('0.1%')` unchanged; `:107` `feeBps).toBe(10) // DEFAULT_FEE_BPS` → `feeTenthsBps).toBe(100) // DEFAULT_FEE_TENTHS_BPS`. +- `send.test.ts:185` `makeVaultConfig({ feeBps: 25 })` → `{ feeTenthsBps: 250 }`; `:184` comment `feeBps` → `feeTenthsBps`; `:199` `feeBps).toBe(25)` → `feeTenthsBps).toBe(250)`; `:215` `feeBps).toBe(10)` → `feeTenthsBps).toBe(100)`. +- `tools.test.ts:388` `privacy.feeBps).toBeGreaterThanOrEqual(0)` → `privacy.feeTenthsBps)…`; `:761` `vault.feeBps).toBe(10)` → `vault.feeTenthsBps).toBe(100)`. +- `routes/vault-deposit-tx.test.ts:72` `res.body.feeBps` → `res.body.feeTenthsBps`. +- `fixtures/user-tool-mocks.ts:68` `feeBps: number` → `feeTenthsBps: number`; `:80` `feeBps: 10` → `feeTenthsBps: 100`. +- Send-output `privacy` mocks (not asserted, but must match the renamed type): rename key `feeBps` → `feeTenthsBps`, value `50` → `500`, in `send-private-to-sns.test.ts:46`, `agent-signing-wrapper.test.ts:73,142,172`, `agent-display-formatter.test.ts:19,74`, `integration/signing-callback-roundtrip.test.ts:54,103`. + +Also update the `makeVaultConfig` helper (wherever `send.test.ts`/`status.test.ts` import it) if it hardcodes a `feeBps` key. + +- [ ] **Step 2: Run agent tests — verify they FAIL** + +Run: `pnpm --filter @sipher/agent test -- --run` +Expected: FAIL (type errors + `feeTenthsBps` undefined on responses still emitting `feeBps`). + +- [ ] **Step 3: Rename in `status.ts`** + +`:61` `feeBps: DEFAULT_FEE_BPS,` → `feeTenthsBps: DEFAULT_FEE_TENTHS_BPS,`; `:62` `feePercent: \`${DEFAULT_FEE_BPS / 100}%\`,` → `\`${DEFAULT_FEE_TENTHS_BPS / 1000}%\`,`; `:73` `const feePercent = \`${config.feeBps / 100}%\`` → `\`${config.feeTenthsBps / 1000}%\``; `:89` `feeBps: config.feeBps,` → `feeTenthsBps: config.feeTenthsBps,`. Update the `DEFAULT_FEE_BPS` import → `DEFAULT_FEE_TENTHS_BPS`. + +- [ ] **Step 4: Rename in `send.ts`** + +`:107` comment `Fetch live fee_bps` → `fee_tenths_bps`; `:109` `const feeBps = config?.feeBps ?? DEFAULT_FEE_BPS` → `const feeTenthsBps = config?.feeTenthsBps ?? DEFAULT_FEE_TENTHS_BPS`; `:110` `const feePercent = feeBps / 100` → `const feePercent = feeTenthsBps / 1000`; `:128` and `:270` `feeBps,` → `feeTenthsBps,`. Update the import. (`feePercent` is still used at `:122,129,264,271` — those stay, now derived from the tenths value.) + +- [ ] **Step 5: Rename in `vault-deposit-tx.ts`** + +`:73` `feeBps: DEFAULT_FEE_BPS,` → `feeTenthsBps: DEFAULT_FEE_TENTHS_BPS,`. Update the import. + +- [ ] **Step 6: Run agent tests + typecheck — verify PASS** + +Run: `pnpm --filter @sipher/agent test -- --run && pnpm --filter @sipher/agent typecheck` +Expected: PASS. (Re-run once if a known flake reds.) + +- [ ] **Step 7: Commit** + +```bash +git add packages/agent/src/tools packages/agent/src/routes/vault-deposit-tx.ts packages/agent/tests +git commit -S -m "fix(agent): surface vault fee as feeTenthsBps (÷1000 percent)" +``` + +--- + +## Task 4: reference adapter `examples/vault-privacy-provider` + +**Files:** +- Modify: `examples/vault-privacy-provider/src/types.ts:41-42` +- Modify: `examples/vault-privacy-provider/src/provider.ts:5,21,23-24,82` +- Test: `examples/vault-privacy-provider/test/provider.test.ts` (8 sites) +- Modify: `docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md` (the generic fee row → `feeTenthsBps` + note the single ÷10 to an integrator's `feeBps` happens downstream) + +**Interfaces:** +- Consumes: `DEFAULT_FEE_TENTHS_BPS` from `@sipher/sdk`; `buildPrivateSendSolTx` (÷100_000 fee, Task 1). +- Produces: `VaultPrivacyProvider.feeTenthsBps: number`; `previewWithdraw` uses `/100_000n`. + +- [ ] **Step 1: Flip the example tests (failing tests)** + +`examples/vault-privacy-provider/test/provider.test.ts`: +- `:16-17` `mockConn` opt `feeBps` → `feeTenthsBps`, default `10` → `100`; `:19` comment `fee_bps` → `fee_tenths_bps`; `:20` `writeUInt16LE(feeBps, 40)` → `writeUInt16LE(feeTenthsBps, 40)`. +- `:43-46` default preview test → `expect(p.feeTenthsBps).toBe(100)`; keep `previewWithdraw(2_000_000n)` → `{ feeLamports: 2_000n, netLamports: 1_998_000n }` (100 tenths on 2M = 2000, unchanged). +- **Add an explicit tenths-precision guard** (the default case is divisor-invariant, so this is what actually exercises the fix): +```ts + 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 }) + }) +``` +- `:89` `mockConn({ feeBps: 10 })` → `mockConn({ feeTenthsBps: 100 })` (the on-chain-path `privateWithdraw` test; `:91-92` `feeLamports 2_000n`/`withdrawnLamports 1_998_000n` unchanged). + +- [ ] **Step 2: Run example tests — verify they FAIL** + +Run: `pnpm --filter vault-privacy-provider test -- --run` (or `cd examples/vault-privacy-provider && pnpm test -- --run`) +Expected: FAIL — `feeTenthsBps` undefined, and the `75 → 1_500n` guard mismatches the current `BigInt(this.feeBps)/10_000n`. + +- [ ] **Step 3: Migrate the interface + provider** + +`examples/vault-privacy-provider/src/types.ts:41-42` — comment `Advertised withdraw fee (bps)` → `Advertised withdraw fee (tenths of a bps)`; `readonly feeBps: number` → `readonly feeTenthsBps: number`. + +`examples/vault-privacy-provider/src/provider.ts`: +- `:5` import `DEFAULT_FEE_BPS` → `DEFAULT_FEE_TENTHS_BPS`. +- `:21` `readonly feeBps: number` → `readonly feeTenthsBps: number`. +- `:23` `opts: { feeBps?: number } = {}` → `opts: { feeTenthsBps?: number } = {}`. +- `:24` `this.feeBps = opts.feeBps ?? DEFAULT_FEE_BPS` → `this.feeTenthsBps = opts.feeTenthsBps ?? DEFAULT_FEE_TENTHS_BPS`. +- `:82` `const feeLamports = (grossLamports * BigInt(this.feeBps)) / 10_000n` → `const feeLamports = (grossLamports * BigInt(this.feeTenthsBps)) / 100_000n`. + +- [ ] **Step 4: Update the generic example spec (naming-clean)** + +In `docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md`, change the fee row/text from whole-bps `feeBps` to `feeTenthsBps`, and add: "an integrator whose interface uses whole-bps `feeBps` receives `feeTenthsBps / 10` at the downstream port — the sole conversion, kept out of this repo." Do not name any external party. + +- [ ] **Step 5: Naming gate** + +Run the partner/competitor deny-list grep (pattern supplied out-of-band — never commit it here) over `examples/vault-privacy-provider` and `docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md`. +Expected: no output. + +- [ ] **Step 6: Run example tests + typecheck — verify PASS** + +Run: `pnpm --filter vault-privacy-provider test -- --run && pnpm --filter vault-privacy-provider typecheck` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add examples/vault-privacy-provider docs/superpowers/specs/2026-06-25-vault-privacy-provider-example-design.md +git commit -S -m "fix(example): vault-privacy-provider fee → tenths-bps" +``` + +--- + +## Task 5: whole-branch verification + review + PR + +**Files:** none (verification + review). + +- [ ] **Step 1: Full workspace test + typecheck + build** + +Run: +```bash +cd /Users/rector/local-dev/sipher-wt/vault-fee-tenths-bps +pnpm install +pnpm test -- --run +pnpm typecheck +pnpm build +cd packages/agent && pnpm test -- --run && cd ../.. +``` +Expected: all green (re-run any known flake once). If anything reds, STOP and fix before proceeding. + +- [ ] **Step 2: Confirm the guard-rails held** + +Run: +```bash +# chains.ts per-chain list untouched: +git diff --stat origin/main -- packages/agent/src/routes/chains.ts # expect: empty +# no vault fee_bps / 10_000n left in migrated files: +grep -rnE 'feeBps|/ *10_000n' packages/sdk/src packages/agent/src/tools examples/vault-privacy-provider/src +# → only allowed hits: sip_privacy layout comments in privacy.ts/privacy-sol.ts +# naming gate over the whole diff (deny-list pattern supplied out-of-band — do NOT inline it here): +# run the partner/competitor deny-list grep over `git diff --name-only origin/main`; expect empty +``` + +- [ ] **Step 3: `/code-review` the whole branch** + +Run the `/code-review` skill over the branch diff (high effort). Address findings (one commit per fix, GPG-signed). Re-run tests after fixes. + +- [ ] **Step 4: Push + open PR (do NOT self-merge)** + +```bash +git push -u origin fix/vault-fee-tenths-bps +gh pr create --repo sip-protocol/sipher --base main --head fix/vault-fee-tenths-bps \ + --title "fix(sdk): vault fee → tenths-bps (feeTenthsBps); publish-gate for @sipher/sdk 0.2.0" \ + --body "" +``` +RECTOR reviews/merges. **npm publish of `@sipher/sdk@0.2.0` remains a separate, explicit-go step after merge.** + +--- + +## Self-Review (author checklist — completed) + +- **Spec coverage:** every spec §4 site → a task (SDK core T1, IDL/scripts/version T2, agent T3, example+generic-spec T4, verify/publish-gate T5). ✓ +- **Placeholder scan:** no TBD/TODO; all code shown; the one open unknown (existing SPL fee assertion) is handled as "add if absent." ✓ +- **Type consistency:** `feeTenthsBps` / `DEFAULT_FEE_TENTHS_BPS` / `MAX_FEE_TENTHS_BPS` / `/ 100_000n` used identically across all tasks; `feePercent` retained as string. ✓ +- **Guard-rails:** `sip_privacy` comments (privacy.ts:149 / privacy-sol.ts:119) and `chains.ts` explicitly excluded in T1/T3 + verified in T5. ✓ 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 4abfb0b..01c207f 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 @@ -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 @@ -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 @@ -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. diff --git a/docs/superpowers/specs/2026-07-02-sipher-sdk-vault-fee-tenths-bps-design.md b/docs/superpowers/specs/2026-07-02-sipher-sdk-vault-fee-tenths-bps-design.md new file mode 100644 index 0000000..990683b --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-sipher-sdk-vault-fee-tenths-bps-design.md @@ -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. diff --git a/examples/vault-privacy-provider/src/provider.ts b/examples/vault-privacy-provider/src/provider.ts index ee7d231..ab19a93 100644 --- a/examples/vault-privacy-provider/src/provider.ts +++ b/examples/vault-privacy-provider/src/provider.ts @@ -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 { @@ -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 { @@ -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 } } diff --git a/examples/vault-privacy-provider/src/types.ts b/examples/vault-privacy-provider/src/types.ts index 5f743f5..327bba9 100644 --- a/examples/vault-privacy-provider/src/types.ts +++ b/examples/vault-privacy-provider/src/types.ts @@ -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 diff --git a/examples/vault-privacy-provider/test/provider.test.ts b/examples/vault-privacy-provider/test/provider.test.ts index 8950aab..2dedbe3 100644 --- a/examples/vault-privacy-provider/test/provider.test.ts +++ b/examples/vault-privacy-provider/test/provider.test.ts @@ -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. @@ -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() @@ -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) diff --git a/packages/agent/src/routes/vault-deposit-tx.ts b/packages/agent/src/routes/vault-deposit-tx.ts index a90ee36..cea52ba 100644 --- a/packages/agent/src/routes/vault-deposit-tx.ts +++ b/packages/agent/src/routes/vault-deposit-tx.ts @@ -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() @@ -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) { diff --git a/packages/agent/src/tools/send.ts b/packages/agent/src/tools/send.ts index af52c3b..b4deafe 100644 --- a/packages/agent/src/tools/send.ts +++ b/packages/agent/src/tools/send.ts @@ -17,7 +17,7 @@ import { toBaseUnits, fromBaseUnits, getVaultConfig, - DEFAULT_FEE_BPS, + DEFAULT_FEE_TENTHS_BPS, } from '@sipher/sdk' import { loadNetworkConfig } from '../config/network.js' @@ -46,7 +46,7 @@ export interface SendToolResult { stealthAddress: string commitmentGenerated: boolean viewingKeyHashIncluded: boolean - feeBps: number + feeTenthsBps: number estimatedFee: string netAmount: string | null } @@ -104,10 +104,10 @@ export async function executeSend(params: SendParams): Promise { const network = loadNetworkConfig().clusterName const connection = createConnection(network) - // Fetch live fee_bps from on-chain config + // Fetch live fee_tenths_bps from on-chain config const config = await getVaultConfig(connection) - const feeBps = config?.feeBps ?? DEFAULT_FEE_BPS - const feePercent = feeBps / 100 + const feeTenthsBps = config?.feeTenthsBps ?? DEFAULT_FEE_TENTHS_BPS + const feePercent = feeTenthsBps / 1000 // If no wallet, return the preview without building a tx if (!params.wallet) { @@ -125,7 +125,7 @@ export async function executeSend(params: SendParams): Promise { stealthAddress: '', commitmentGenerated: true, viewingKeyHashIncluded: true, - feeBps, + feeTenthsBps, estimatedFee: `${(params.amount * feePercent) / 100} ${token}`, netAmount: null, }, @@ -267,7 +267,7 @@ export async function executeSend(params: SendParams): Promise { stealthAddress: stealthPubkey.toBase58(), commitmentGenerated: isStealthMetaAddress, viewingKeyHashIncluded: isStealthMetaAddress, - feeBps, + feeTenthsBps, estimatedFee: fromBaseUnits(result.feeAmount, decimals) + ` ${token}`, netAmount: fromBaseUnits(result.netAmount, decimals), }, diff --git a/packages/agent/src/tools/status.ts b/packages/agent/src/tools/status.ts index 51b8afe..8035858 100644 --- a/packages/agent/src/tools/status.ts +++ b/packages/agent/src/tools/status.ts @@ -3,7 +3,7 @@ import { createConnection, getVaultConfig, SIPHER_VAULT_PROGRAM_ID, - DEFAULT_FEE_BPS, + DEFAULT_FEE_TENTHS_BPS, DEFAULT_REFUND_TIMEOUT, } from '@sipher/sdk' import { loadNetworkConfig } from '../config/network.js' @@ -19,7 +19,7 @@ export interface StatusToolResult { vault: { programId: string paused: boolean - feeBps: number + feeTenthsBps: number feePercent: string refundTimeout: number refundTimeoutHuman: string @@ -58,8 +58,8 @@ export async function executeStatus(): Promise { vault: { programId: SIPHER_VAULT_PROGRAM_ID.toBase58(), paused: false, - feeBps: DEFAULT_FEE_BPS, - feePercent: `${DEFAULT_FEE_BPS / 100}%`, + feeTenthsBps: DEFAULT_FEE_TENTHS_BPS, + feePercent: `${DEFAULT_FEE_TENTHS_BPS / 1000}%`, refundTimeout: DEFAULT_REFUND_TIMEOUT, refundTimeoutHuman: `${DEFAULT_REFUND_TIMEOUT / 3600} hours`, totalDeposits: 0, @@ -70,7 +70,7 @@ export async function executeStatus(): Promise { } } - const feePercent = `${config.feeBps / 100}%` + const feePercent = `${config.feeTenthsBps / 1000}%` const timeoutHours = config.refundTimeout / 3600 const refundTimeoutHuman = timeoutHours >= 1 ? `${timeoutHours} hours` @@ -86,7 +86,7 @@ export async function executeStatus(): Promise { vault: { programId: SIPHER_VAULT_PROGRAM_ID.toBase58(), paused: config.paused, - feeBps: config.feeBps, + feeTenthsBps: config.feeTenthsBps, feePercent, refundTimeout: config.refundTimeout, refundTimeoutHuman, diff --git a/packages/agent/tests/agent-display-formatter.test.ts b/packages/agent/tests/agent-display-formatter.test.ts index 49becf4..0e2e195 100644 --- a/packages/agent/tests/agent-display-formatter.test.ts +++ b/packages/agent/tests/agent-display-formatter.test.ts @@ -16,7 +16,7 @@ describe('formatSigningDisplay', () => { stealthAddress: 'StealthABC', commitmentGenerated: true, viewingKeyHashIncluded: true, - feeBps: 50, + feeTenthsBps: 500, estimatedFee: '0.0075 SOL', netAmount: '1.4925', }, @@ -71,7 +71,7 @@ describe('formatSigningDisplay', () => { stealthAddress: 'S', commitmentGenerated: false, viewingKeyHashIncluded: false, - feeBps: 50, + feeTenthsBps: 500, estimatedFee: '0.005 SOL', netAmount: '0.995', }, diff --git a/packages/agent/tests/agent-signing-wrapper.test.ts b/packages/agent/tests/agent-signing-wrapper.test.ts index 38a0887..a38e606 100644 --- a/packages/agent/tests/agent-signing-wrapper.test.ts +++ b/packages/agent/tests/agent-signing-wrapper.test.ts @@ -70,7 +70,7 @@ describe('chatStream signing-wait wrapper (wrapWithSigning)', () => { stealthAddress: 'X', commitmentGenerated: true, viewingKeyHashIncluded: true, - feeBps: 50, + feeTenthsBps: 500, estimatedFee: '0.005 SOL', netAmount: '0.995', }, @@ -139,7 +139,7 @@ describe('chatStream signing-wait wrapper (wrapWithSigning)', () => { action: 'send', status: 'awaiting_signature' as const, serializedTx: null, - privacy: { stealthAddress: '', commitmentGenerated: false, viewingKeyHashIncluded: false, feeBps: 50, estimatedFee: '0.005 SOL', netAmount: null }, + privacy: { stealthAddress: '', commitmentGenerated: false, viewingKeyHashIncluded: false, feeTenthsBps: 500, estimatedFee: '0.005 SOL', netAmount: null }, })) const queue: SigningQueueEvent[] = [] @@ -169,7 +169,7 @@ describe('chatStream signing-wait wrapper (wrapWithSigning)', () => { it('skips signing pause when input.wallet is missing', async () => { const baseExecutor = vi.fn(async () => ({ action: 'send', status: 'awaiting_signature' as const, serializedTx: 'TX', - privacy: { stealthAddress: 'X', commitmentGenerated: true, viewingKeyHashIncluded: true, feeBps: 50, estimatedFee: '0', netAmount: '1' }, + privacy: { stealthAddress: 'X', commitmentGenerated: true, viewingKeyHashIncluded: true, feeTenthsBps: 500, estimatedFee: '0', netAmount: '1' }, })) const queue: SigningQueueEvent[] = [] const wrapped = wrapWithSigning(baseExecutor, { diff --git a/packages/agent/tests/fixtures/user-tool-mocks.ts b/packages/agent/tests/fixtures/user-tool-mocks.ts index f8580a0..3705a08 100644 --- a/packages/agent/tests/fixtures/user-tool-mocks.ts +++ b/packages/agent/tests/fixtures/user-tool-mocks.ts @@ -65,7 +65,7 @@ export function makeVaultBalance( export interface VaultConfigShape { paused: boolean - feeBps: number + feeTenthsBps: number refundTimeout: number totalDeposits: number totalDepositors: number @@ -77,7 +77,7 @@ export function makeVaultConfig( ): VaultConfigShape { return { paused: false, - feeBps: 10, + feeTenthsBps: 100, refundTimeout: 86400, totalDeposits: 5, totalDepositors: 3, diff --git a/packages/agent/tests/integration/signing-callback-roundtrip.test.ts b/packages/agent/tests/integration/signing-callback-roundtrip.test.ts index e260ed5..e284781 100644 --- a/packages/agent/tests/integration/signing-callback-roundtrip.test.ts +++ b/packages/agent/tests/integration/signing-callback-roundtrip.test.ts @@ -51,7 +51,7 @@ describe('signing callback round-trip → growth-hook emit', () => { stealthAddress: 'StealthABC', commitmentGenerated: true, viewingKeyHashIncluded: true, - feeBps: 50, + feeTenthsBps: 500, estimatedFee: '0.005 SOL', netAmount: '0.995', }, @@ -100,7 +100,7 @@ describe('signing callback round-trip → growth-hook emit', () => { stealthAddress: 'StealthABC', commitmentGenerated: true, viewingKeyHashIncluded: true, - feeBps: 50, + feeTenthsBps: 500, estimatedFee: '0.005 SOL', netAmount: '0.995', }, diff --git a/packages/agent/tests/routes/vault-deposit-tx.test.ts b/packages/agent/tests/routes/vault-deposit-tx.test.ts index d5de103..bfa1283 100644 --- a/packages/agent/tests/routes/vault-deposit-tx.test.ts +++ b/packages/agent/tests/routes/vault-deposit-tx.test.ts @@ -69,7 +69,7 @@ describe('POST /api/vault/deposit-tx', () => { amountBaseUnits: '1500000000', network: 'devnet', }) - expect(typeof res.body.feeBps).toBe('number') + expect(typeof res.body.feeTenthsBps).toBe('number') expect(executeDeposit).toHaveBeenCalledWith({ amount: 1.5, token: 'SOL', wallet: TEST_WALLET }) }) diff --git a/packages/agent/tests/send-private-to-sns.test.ts b/packages/agent/tests/send-private-to-sns.test.ts index 4b2391c..bb84405 100644 --- a/packages/agent/tests/send-private-to-sns.test.ts +++ b/packages/agent/tests/send-private-to-sns.test.ts @@ -43,7 +43,7 @@ const makeSendOk = () => ({ stealthAddress: 'Stealth111', commitmentGenerated: true, viewingKeyHashIncluded: true, - feeBps: 50, + feeTenthsBps: 500, estimatedFee: '0.05 USDC', netAmount: '9.95', }, diff --git a/packages/agent/tests/send.test.ts b/packages/agent/tests/send.test.ts index 7346027..1883172 100644 --- a/packages/agent/tests/send.test.ts +++ b/packages/agent/tests/send.test.ts @@ -51,7 +51,7 @@ vi.mock('@sipher/sdk', () => ({ return frac === 0n ? whole.toString() : `${whole}.${frac.toString().padStart(decimals, '0').replace(/0+$/, '')}` }, getVaultConfig: mockGetVaultConfig, - DEFAULT_FEE_BPS: 10, + DEFAULT_FEE_TENTHS_BPS: 100, })) vi.mock('@sip-protocol/sdk', () => ({ @@ -181,8 +181,8 @@ describe('executeSend — stealth meta-address validation', () => { }) describe('executeSend — preview path (no wallet)', () => { - it('returns prepared shape without building tx, using on-chain feeBps', async () => { - mockGetVaultConfig.mockResolvedValueOnce(makeVaultConfig({ feeBps: 25 })) + it('returns prepared shape without building tx, using on-chain feeTenthsBps', async () => { + mockGetVaultConfig.mockResolvedValueOnce(makeVaultConfig({ feeTenthsBps: 250 })) const result = await executeSend({ amount: 1.5, @@ -196,14 +196,14 @@ describe('executeSend — preview path (no wallet)', () => { expect(result.recipient).toBe(VALID_RECIPIENT) expect(result.status).toBe('awaiting_signature') expect(result.serializedTx).toBeNull() - expect(result.privacy.feeBps).toBe(25) + expect(result.privacy.feeTenthsBps).toBe(250) expect(result.privacy.stealthAddress).toBe('') expect(result.privacy.netAmount).toBeNull() expect(result.message).toContain('0.25%') expect(result.message).toContain('Connect wallet') }) - it('falls back to DEFAULT_FEE_BPS when getVaultConfig returns null', async () => { + it('falls back to DEFAULT_FEE_TENTHS_BPS when getVaultConfig returns null', async () => { mockGetVaultConfig.mockResolvedValueOnce(null) const result = await executeSend({ @@ -212,7 +212,7 @@ describe('executeSend — preview path (no wallet)', () => { recipient: VALID_RECIPIENT, }) - expect(result.privacy.feeBps).toBe(10) + expect(result.privacy.feeTenthsBps).toBe(100) }) it('does not call buildPrivateSendTx in preview', async () => { diff --git a/packages/agent/tests/status.test.ts b/packages/agent/tests/status.test.ts index 26cd272..b2f59e9 100644 --- a/packages/agent/tests/status.test.ts +++ b/packages/agent/tests/status.test.ts @@ -15,7 +15,7 @@ vi.mock('@sipher/sdk', () => ({ createConnection: mockCreateConnection, getVaultConfig: mockGetVaultConfig, SIPHER_VAULT_PROGRAM_ID: { toBase58: () => 'S1Phr5rmDfkZTyLXzH5qUHeiqZS3Uf517SQzRbU4kHB' }, - DEFAULT_FEE_BPS: 10, + DEFAULT_FEE_TENTHS_BPS: 100, DEFAULT_REFUND_TIMEOUT: 86400, })) @@ -46,7 +46,7 @@ describe('executeStatus — config found', () => { expect(result.status).toBe('success') expect(result.vault.configFound).toBe(true) expect(result.vault.paused).toBe(false) - expect(result.vault.feeBps).toBe(10) + expect(result.vault.feeTenthsBps).toBe(100) expect(result.vault.feePercent).toBe('0.1%') expect(result.vault.refundTimeout).toBe(86400) expect(result.vault.refundTimeoutHuman).toBe('24 hours') @@ -104,7 +104,7 @@ describe('executeStatus — config not found', () => { expect(result.vault.configFound).toBe(false) expect(result.vault.paused).toBe(false) - expect(result.vault.feeBps).toBe(10) // DEFAULT_FEE_BPS + expect(result.vault.feeTenthsBps).toBe(100) // DEFAULT_FEE_TENTHS_BPS expect(result.vault.refundTimeout).toBe(86400) // DEFAULT_REFUND_TIMEOUT expect(result.vault.totalDeposits).toBe(0) expect(result.vault.totalDepositors).toBe(0) diff --git a/packages/agent/tests/tools.test.ts b/packages/agent/tests/tools.test.ts index 3098a9c..4ace86e 100644 --- a/packages/agent/tests/tools.test.ts +++ b/packages/agent/tests/tools.test.ts @@ -385,7 +385,7 @@ describe('executeSend', () => { expect(result.serializedTx).toBeNull() expect(result.privacy.commitmentGenerated).toBe(true) expect(result.privacy.viewingKeyHashIncluded).toBe(true) - expect(result.privacy.feeBps).toBeGreaterThanOrEqual(0) + expect(result.privacy.feeTenthsBps).toBeGreaterThanOrEqual(0) }) it('normalizes token to uppercase', async () => { @@ -758,7 +758,7 @@ describe('executeStatus', () => { expect(result.status).toBe('success') expect(result.vault.programId).toBe('S1Phr5rmDfkZTyLXzH5qUHeiqZS3Uf517SQzRbU4kHB') expect(result.vault.configFound).toBe(false) - expect(result.vault.feeBps).toBe(10) + expect(result.vault.feeTenthsBps).toBe(100) expect(result.vault.refundTimeout).toBe(86400) expect(result.vault.paused).toBe(false) expect(result.vault.totalDeposits).toBe(0) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 03c8213..75bb94c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sipher/sdk", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/sdk/scripts/devnet-check.ts b/packages/sdk/scripts/devnet-check.ts index 44ed023..01d89ba 100644 --- a/packages/sdk/scripts/devnet-check.ts +++ b/packages/sdk/scripts/devnet-check.ts @@ -39,7 +39,7 @@ async function main() { if (config) { console.log(' Status: INITIALIZED') console.log(` Authority: ${config.authority.toBase58()}`) - console.log(` Fee BPS: ${config.feeBps}`) + console.log(` Fee: ${config.feeTenthsBps} tenths-bps (${config.feeTenthsBps / 10} bps)`) console.log(` Refund timeout: ${config.refundTimeout}s`) console.log(` Paused: ${config.paused}`) console.log(` Total deposits: ${config.totalDeposits}`) diff --git a/packages/sdk/src/config.ts b/packages/sdk/src/config.ts index 2181f57..e7fda34 100644 --- a/packages/sdk/src/config.ts +++ b/packages/sdk/src/config.ts @@ -44,15 +44,17 @@ export const SIP_TRANSFER_RECORD_SEED = Buffer.from('transfer_record') /** 24 hours in seconds */ export const DEFAULT_REFUND_TIMEOUT = 86400 -/** 0.10% fee */ -export const DEFAULT_FEE_BPS = 10 -/** Max 1% fee */ -export const MAX_FEE_BPS = 100 +/** 0.10% fee (100 tenths-of-a-bps) */ +export const DEFAULT_FEE_TENTHS_BPS = 100 +/** Max 1% fee (1000 tenths-of-a-bps) */ +export const MAX_FEE_TENTHS_BPS = 1000 +/** Vault fee divisor: fee = amount * feeTenthsBps / FEE_TENTHS_BPS_DENOMINATOR (mirrors on-chain constants.rs) */ +export const FEE_TENTHS_BPS_DENOMINATOR = 100_000n // ───────────────────────────────────────────────────────────────────────────── // Account data offsets (after 8-byte Anchor discriminator) // -// VaultConfig: authority(32) + fee_bps(2) + refund_timeout(8) + paused(1) +// VaultConfig: authority(32) + fee_tenths_bps(2) + refund_timeout(8) + paused(1) // + total_deposits(8) + total_depositors(8) + bump(1) // + pending_authority(1+32) = 93 // diff --git a/packages/sdk/src/idl/sipher_vault.json b/packages/sdk/src/idl/sipher_vault.json index 16f4d0b..59e4929 100644 --- a/packages/sdk/src/idl/sipher_vault.json +++ b/packages/sdk/src/idl/sipher_vault.json @@ -455,7 +455,7 @@ "name": "collect_fee_sol", "docs": [ "Authority-only: drain lamports above the rent-exempt minimum from the native", - "SOL fee PDA (`sol_fee`). Fees accrue during `withdraw_private_sol` (fee_bps", + "SOL fee PDA (`sol_fee`). Fees accrue during `withdraw_private_sol` (fee_tenths_bps", "per withdrawal). Pass `amount = 0` to collect ALL collectable lamports.", "", "Safety: Uses the Task-7 checked lamport mutation pattern \u2014 every new balance", @@ -1141,7 +1141,7 @@ ], "args": [ { - "name": "fee_bps", + "name": "fee_tenths_bps", "type": "u16" }, { @@ -1625,8 +1625,8 @@ { "name": "update_fee", "docs": [ - "Update the protocol fee (basis points). Authority-only, capped at", - "MAX_FEE_BPS. Lets the authority adjust the fee without a redeploy." + "Update the protocol fee (tenths-of-bps). Authority-only, capped at", + "MAX_FEE_TENTHS_BPS. Lets the authority adjust the fee without a redeploy." ], "discriminator": [ 232, @@ -1674,7 +1674,7 @@ ], "args": [ { - "name": "new_fee_bps", + "name": "new_fee_tenths_bps", "type": "u16" } ] @@ -1970,7 +1970,7 @@ "of the program-owned `SolVault` PDA (no system CPI \u2014 the source is PDA-owned):", "1. Guards: not paused, amount > 0.", "2. Debit-first: reduce `DepositRecord.balance` before moving any lamports.", - "3. Fee split: `fee = amount \u00b7 fee_bps / 10_000`, `net = amount \u2212 fee`.", + "3. Fee split: `fee = amount \u00b7 fee_tenths_bps / 100_000`, `net = amount \u2212 fee`.", "4. Checked lamport mutation: compute every new balance with checked ops", "(BPF release WRAPS on `+=`/`-=`, which would be a vault drain), enforce", "the rent-reserve guard on the vault debit, THEN assign.", @@ -2577,11 +2577,11 @@ "type": "pubkey" }, { - "name": "old_fee_bps", + "name": "old_fee_tenths_bps", "type": "u16" }, { - "name": "new_fee_bps", + "name": "new_fee_tenths_bps", "type": "u16" }, { @@ -2625,7 +2625,7 @@ "type": "pubkey" }, { - "name": "fee_bps", + "name": "fee_tenths_bps", "type": "u16" }, { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 7bf95c1..8e1ff79 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -28,8 +28,9 @@ export { VAULT_SOL_SEED, FEE_SOL_SEED, DEFAULT_REFUND_TIMEOUT, - DEFAULT_FEE_BPS, - MAX_FEE_BPS, + DEFAULT_FEE_TENTHS_BPS, + MAX_FEE_TENTHS_BPS, + FEE_TENTHS_BPS_DENOMINATOR, ANCHOR_DISCRIMINATOR_SIZE, VAULT_CONFIG_SIZE, DEPOSIT_RECORD_SIZE, diff --git a/packages/sdk/src/privacy-sol.ts b/packages/sdk/src/privacy-sol.ts index e47787c..288108f 100644 --- a/packages/sdk/src/privacy-sol.ts +++ b/packages/sdk/src/privacy-sol.ts @@ -12,6 +12,8 @@ import { SIP_TRANSFER_RECORD_SEED, NATIVE_SOL_MINT, ANCHOR_DISCRIMINATOR_SIZE, + DEFAULT_FEE_TENTHS_BPS, + FEE_TENTHS_BPS_DENOMINATOR, } from './config.js' import { anchorDiscriminator, @@ -108,10 +110,10 @@ export async function buildPrivateSendSolTx( connection.getAccountInfo(stealthPubkey), ]) - let feeBps = 10 // fallback to default + let feeTenthsBps = DEFAULT_FEE_TENTHS_BPS // fallback to default if (configInfo) { - // fee_bps at offset 8 (disc) + 32 (authority) = 40, u16 LE - feeBps = configInfo.data.readUInt16LE(40) + // fee_tenths_bps at offset 8 (disc) + 32 (authority) = 40, u16 LE + feeTenthsBps = configInfo.data.readUInt16LE(40) } let sipTotalTransfers = 0n @@ -129,7 +131,7 @@ export async function buildPrivateSendSolTx( SIP_PRIVACY_PROGRAM_ID ) - const feeAmount = (amount * BigInt(feeBps)) / 10_000n + const feeAmount = (amount * BigInt(feeTenthsBps)) / FEE_TENTHS_BPS_DENOMINATOR const netAmount = amount - feeAmount // Rent-exempt guard: the stealth recipient is a plain system account. The runtime diff --git a/packages/sdk/src/privacy.ts b/packages/sdk/src/privacy.ts index 441f386..373aac9 100644 --- a/packages/sdk/src/privacy.ts +++ b/packages/sdk/src/privacy.ts @@ -20,6 +20,8 @@ import { SIP_CONFIG_SEED, SIP_TRANSFER_RECORD_SEED, ANCHOR_DISCRIMINATOR_SIZE, + DEFAULT_FEE_TENTHS_BPS, + FEE_TENTHS_BPS_DENOMINATOR, } from './config.js' import { anchorDiscriminator, deriveVaultConfigPDA } from './vault.js' import { WITHDRAW_EVENT_MIN_SIZE, WITHDRAW_EVENT_WITH_MINT_SIZE } from './events.js' @@ -139,10 +141,10 @@ export async function buildPrivateSendTx( connection.getAccountInfo(sipConfigPDA), ]) - let feeBps = 10 // fallback to default + let feeTenthsBps = DEFAULT_FEE_TENTHS_BPS // fallback to default if (configInfo) { - // fee_bps is at offset 8 (discriminator) + 32 (authority) = 40, u16 LE - feeBps = configInfo.data.readUInt16LE(40) + // fee_tenths_bps is at offset 8 (discriminator) + 32 (authority) = 40, u16 LE + feeTenthsBps = configInfo.data.readUInt16LE(40) } // Read total_transfers from sip_privacy Config to derive the TransferRecord PDA. @@ -162,7 +164,7 @@ export async function buildPrivateSendTx( SIP_PRIVACY_PROGRAM_ID ) - const feeAmount = (amount * BigInt(feeBps)) / 10_000n + const feeAmount = (amount * BigInt(feeTenthsBps)) / FEE_TENTHS_BPS_DENOMINATOR const netAmount = amount - feeAmount // Serialize instruction data diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index cfa3fd9..a507f67 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -6,7 +6,7 @@ import type { PublicKey, Transaction } from '@solana/web3.js' export interface VaultConfig { authority: PublicKey - feeBps: number + feeTenthsBps: number refundTimeout: number paused: boolean totalDeposits: number diff --git a/packages/sdk/src/vault.ts b/packages/sdk/src/vault.ts index a2d776d..a876736 100644 --- a/packages/sdk/src/vault.ts +++ b/packages/sdk/src/vault.ts @@ -106,7 +106,7 @@ export function anchorDiscriminator(instructionName: string): Buffer { * Deserialize raw account data into VaultConfig. * Layout (after 8-byte discriminator): * authority: Pubkey (32 bytes) - * fee_bps: u16 (2 bytes, LE) + * fee_tenths_bps: u16 (2 bytes, LE) * refund_timeout: i64 (8 bytes, LE) * paused: bool (1 byte) * total_deposits: u64 (8 bytes, LE) @@ -127,7 +127,7 @@ export function deserializeVaultConfig(data: Buffer): VaultConfig { const authority = new PublicKey(data.subarray(offset, offset + 32)) offset += 32 - const feeBps = data.readUInt16LE(offset) + const feeTenthsBps = data.readUInt16LE(offset) offset += 2 const refundTimeout = Number(data.readBigInt64LE(offset)) @@ -146,7 +146,7 @@ export function deserializeVaultConfig(data: Buffer): VaultConfig { return { authority, - feeBps, + feeTenthsBps, refundTimeout, paused, totalDeposits, diff --git a/packages/sdk/tests/privacy-sol.test.ts b/packages/sdk/tests/privacy-sol.test.ts index dd62b36..01d22b0 100644 --- a/packages/sdk/tests/privacy-sol.test.ts +++ b/packages/sdk/tests/privacy-sol.test.ts @@ -22,16 +22,16 @@ const vkHash = new Uint8Array(32).fill(4) const encrypted = new Uint8Array([9, 9, 9]) const proof = new Uint8Array([]) -// Dispatching Connection stub. fee_bps lives at config offset 40 (u16 LE); +// Dispatching Connection stub. fee_tenths_bps lives at config offset 40 (u16 LE); // sip total_transfers at offset 8+32+2+1 = 43 (u64 LE). function mockConn(opts: { - feeBps?: number + feeTenthsBps?: number stealthLamports?: number | null rentExemptMin?: number } = {}): Connection { - const { feeBps = 10, stealthLamports = null, rentExemptMin = 890_880 } = opts + const { feeTenthsBps = 100, stealthLamports = null, rentExemptMin = 890_880 } = opts const configBuf = Buffer.alloc(60) - configBuf.writeUInt16LE(feeBps, 40) + configBuf.writeUInt16LE(feeTenthsBps, 40) const sipBuf = Buffer.alloc(8 + 32 + 2 + 1 + 8) // total_transfers = 0 const [cfg] = deriveVaultConfigPDA() const [sip] = PublicKey.findProgramAddressSync([SIP_CONFIG_SEED], SIP_PRIVACY_PROGRAM_ID) @@ -97,17 +97,27 @@ describe('buildPrivateSendSolTx', () => { it('encodes the discriminator + amount and computes fee/net from config', async () => { const res = await buildPrivateSendSolTx({ ...baseParams, - connection: mockConn({ feeBps: 10, stealthLamports: 1_000_000_000 }), + connection: mockConn({ feeTenthsBps: 10, stealthLamports: 1_000_000_000 }), }) const data = res.transaction.instructions[0].data expect(data.subarray(0, 8).equals(anchorDiscriminator('withdraw_private_sol'))).toBe(true) expect(data.readBigUInt64LE(8)).toBe(2_000_000n) - // 10 bps of 2_000_000 = 2_000 - expect(res.feeAmount).toBe(2_000n) - expect(res.netAmount).toBe(1_998_000n) + // 10 tenths-bps (1 bp) of 2_000_000 = 200 + expect(res.feeAmount).toBe(200n) + expect(res.netAmount).toBe(1_999_800n) expect(res.stealthAddress.toBase58()).toBe(STEALTH.toBase58()) }) + it('computes the fee at tenths-bps precision (÷100_000, not ÷10_000)', async () => { + // 7.5 bps = 75 tenths on 2_000_000 → 1_500 (old whole-bps code gives 15_000) + const res = await buildPrivateSendSolTx({ + ...baseParams, + connection: mockConn({ feeTenthsBps: 75, stealthLamports: 1_000_000_000 }), + }) + expect(res.feeAmount).toBe(1_500n) + expect(res.netAmount).toBe(1_998_500n) + }) + it('throws when a fresh stealth recipient would be left below rent-exempt', async () => { // stealth does not exist (0 lamports); net 1_998_000 < 890_880? No — so make the // payout tiny: amount 1000 => net 999 < rent floor => must throw. diff --git a/packages/sdk/tests/privacy.test.ts b/packages/sdk/tests/privacy.test.ts index 4dd14b3..de94bb1 100644 --- a/packages/sdk/tests/privacy.test.ts +++ b/packages/sdk/tests/privacy.test.ts @@ -9,8 +9,9 @@ import { import { sha256 } from '@noble/hashes/sha2.js' import { sha512 } from '@noble/hashes/sha2.js' import { ed25519 } from '@noble/curves/ed25519' -import type { Connection } from '@solana/web3.js' -import { scanForPayments } from '../src/privacy.js' +import { PublicKey, type Connection } from '@solana/web3.js' +import { scanForPayments, buildPrivateSendTx } from '../src/privacy.js' +import { deriveVaultConfigPDA } from '../src/vault.js' // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -574,3 +575,56 @@ describe('scanForPayments — canonical view-only round-trip', () => { expect(result.payments).toHaveLength(0) }) }) + +// ───────────────────────────────────────────────────────────────────────────── +// buildPrivateSendTx — fee math (tenths-of-bps regression guard) +// ───────────────────────────────────────────────────────────────────────────── + +const SPL_DEPOSITOR = new PublicKey('FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr') +const SPL_TOKEN_MINT = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v') +const SPL_STEALTH_TOKEN_ACCOUNT = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') +const SPL_STEALTH_PUBKEY = new PublicKey('So11111111111111111111111111111111111111112') + +/** + * Minimal Connection stub for buildPrivateSendTx: serves a VaultConfig account + * with fee_tenths_bps at offset 40 (u16 LE) plus a blockhash. The sip_privacy + * Config lookup returns null, which defaults total_transfers to 0. + */ +function mockConnectionWithConfig(feeTenthsBps: number): Connection { + const configBuf = Buffer.alloc(60) + configBuf.writeUInt16LE(feeTenthsBps, 40) + const [cfg] = deriveVaultConfigPDA() + return { + getLatestBlockhash: async () => ({ + blockhash: 'GfVcyD4kkTrj4bKc7WA9sZCin9JDbdT458zqL4zjxx2v', + lastValidBlockHeight: 1, + }), + getAccountInfo: async (pk: PublicKey) => + pk.equals(cfg) ? ({ data: configBuf } as never) : null, + } as unknown as Connection +} + +describe('buildPrivateSendTx — fee math', () => { + it('computes the SPL fee at tenths-bps precision (÷100_000, not ÷10_000)', async () => { + // 7.5 bps = 75 tenths on 2_000_000 → 1_500 (old whole-bps code gives 15_000) + const amount = 2_000_000n + const res = await buildPrivateSendTx({ + connection: mockConnectionWithConfig(75), + depositor: SPL_DEPOSITOR, + tokenMint: SPL_TOKEN_MINT, + amount, + stealthTokenAccount: SPL_STEALTH_TOKEN_ACCOUNT, + stealthPubkey: SPL_STEALTH_PUBKEY, + amountCommitment: new Uint8Array(33).fill(2), + ephemeralPubkey: new Uint8Array(33).fill(3), + viewingKeyHash: new Uint8Array(32).fill(4), + encryptedAmount: new Uint8Array([9, 9, 9]), + proof: new Uint8Array([]), + }) + + expect(res.feeAmount).toBe((amount * 75n) / 100_000n) + expect(res.feeAmount).toBe(1_500n) + expect(res.netAmount).toBe(amount - res.feeAmount) + expect(res.netAmount).toBe(1_998_500n) + }) +}) diff --git a/packages/sdk/tests/vault.test.ts b/packages/sdk/tests/vault.test.ts index 0aed3d5..44f2fcc 100644 --- a/packages/sdk/tests/vault.test.ts +++ b/packages/sdk/tests/vault.test.ts @@ -15,8 +15,8 @@ import { VAULT_TOKEN_SEED, FEE_TOKEN_SEED, DEFAULT_REFUND_TIMEOUT, - DEFAULT_FEE_BPS, - MAX_FEE_BPS, + DEFAULT_FEE_TENTHS_BPS, + MAX_FEE_TENTHS_BPS, ANCHOR_DISCRIMINATOR_SIZE, VAULT_CONFIG_SIZE, DEPOSIT_RECORD_SIZE, @@ -65,8 +65,8 @@ describe('Config', () => { it('has correct default constants', () => { expect(DEFAULT_REFUND_TIMEOUT).toBe(86400) - expect(DEFAULT_FEE_BPS).toBe(10) - expect(MAX_FEE_BPS).toBe(100) + expect(DEFAULT_FEE_TENTHS_BPS).toBe(100) + expect(MAX_FEE_TENTHS_BPS).toBe(1000) }) it('has correct account sizes', () => { @@ -247,7 +247,7 @@ describe('anchorDiscriminator', () => { describe('deserializeVaultConfig', () => { function buildVaultConfigBuffer(overrides: { authority?: PublicKey - feeBps?: number + feeTenthsBps?: number refundTimeout?: number paused?: boolean totalDeposits?: number @@ -256,7 +256,7 @@ describe('deserializeVaultConfig', () => { } = {}): Buffer { const { authority = DEPOSITOR_A, - feeBps = 10, + feeTenthsBps = 100, refundTimeout = 86400, paused = false, totalDeposits = 5, @@ -276,8 +276,8 @@ describe('deserializeVaultConfig', () => { authority.toBuffer().copy(buf, offset) offset += 32 - // fee_bps: u16 LE - buf.writeUInt16LE(feeBps, offset) + // fee_tenths_bps: u16 LE + buf.writeUInt16LE(feeTenthsBps, offset) offset += 2 // refund_timeout: i64 LE @@ -307,7 +307,7 @@ describe('deserializeVaultConfig', () => { const config = deserializeVaultConfig(buf) expect(config.authority.equals(DEPOSITOR_A)).toBe(true) - expect(config.feeBps).toBe(10) + expect(config.feeTenthsBps).toBe(100) expect(config.refundTimeout).toBe(86400) expect(config.paused).toBe(false) expect(config.totalDeposits).toBe(5) @@ -321,10 +321,10 @@ describe('deserializeVaultConfig', () => { expect(config.paused).toBe(true) }) - it('handles max fee BPS', () => { - const buf = buildVaultConfigBuffer({ feeBps: 100 }) + it('handles max fee tenths-bps', () => { + const buf = buildVaultConfigBuffer({ feeTenthsBps: 1000 }) const config = deserializeVaultConfig(buf) - expect(config.feeBps).toBe(100) + expect(config.feeTenthsBps).toBe(1000) }) it('handles zero counters', () => { @@ -350,7 +350,7 @@ describe('deserializeVaultConfig', () => { const buf = Buffer.alloc(100) buildVaultConfigBuffer().copy(buf) const config = deserializeVaultConfig(buf) - expect(config.feeBps).toBe(10) + expect(config.feeTenthsBps).toBe(100) }) }) diff --git a/scripts/devnet-vault-bootstrap.ts b/scripts/devnet-vault-bootstrap.ts index 218c765..7341931 100644 --- a/scripts/devnet-vault-bootstrap.ts +++ b/scripts/devnet-vault-bootstrap.ts @@ -118,7 +118,7 @@ async function assertSolBalance(conn: Connection, pubkey: PublicKey): Promise