@utxopia/sdk 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +108 -0
- package/packages/btc-client/src/esplora-client.ts +153 -0
- package/packages/btc-client/src/index.ts +3 -0
- package/packages/btc-client/src/op-return.ts +93 -0
- package/packages/btc-client/src/types.ts +112 -0
- package/packages/sdk/README.md +277 -0
- package/packages/sdk/dist/announcement-client.d.ts +64 -0
- package/packages/sdk/dist/announcement-client.js +337 -0
- package/packages/sdk/dist/auditor-ciphertext.d.ts +72 -0
- package/packages/sdk/dist/auditor-ciphertext.js +135 -0
- package/packages/sdk/dist/auditor.d.ts +115 -0
- package/packages/sdk/dist/auditor.js +292 -0
- package/packages/sdk/dist/bitcoin/ika.d.ts +47 -0
- package/packages/sdk/dist/bitcoin/ika.js +74 -0
- package/packages/sdk/dist/bitcoin/index.d.ts +2 -0
- package/packages/sdk/dist/bitcoin/index.js +1 -0
- package/packages/sdk/dist/bound-params.d.ts +96 -0
- package/packages/sdk/dist/bound-params.js +249 -0
- package/packages/sdk/dist/chadbuffer.d.ts +124 -0
- package/packages/sdk/dist/chadbuffer.js +405 -0
- package/packages/sdk/dist/claim-link.d.ts +30 -0
- package/packages/sdk/dist/claim-link.js +50 -0
- package/packages/sdk/dist/client.d.ts +233 -0
- package/packages/sdk/dist/client.js +416 -0
- package/packages/sdk/dist/commitment-tree.d.ts +268 -0
- package/packages/sdk/dist/commitment-tree.js +564 -0
- package/packages/sdk/dist/config.d.ts +177 -0
- package/packages/sdk/dist/config.js +426 -0
- package/packages/sdk/dist/core/esplora.d.ts +110 -0
- package/packages/sdk/dist/core/esplora.js +187 -0
- package/packages/sdk/dist/core/mempool.d.ts +60 -0
- package/packages/sdk/dist/core/mempool.js +107 -0
- package/packages/sdk/dist/crypto-babyjub.d.ts +105 -0
- package/packages/sdk/dist/crypto-babyjub.js +312 -0
- package/packages/sdk/dist/crypto-ed25519.d.ts +116 -0
- package/packages/sdk/dist/crypto-ed25519.js +235 -0
- package/packages/sdk/dist/crypto.d.ts +59 -0
- package/packages/sdk/dist/crypto.js +140 -0
- package/packages/sdk/dist/event-client.d.ts +64 -0
- package/packages/sdk/dist/event-client.js +165 -0
- package/packages/sdk/dist/events.d.ts +127 -0
- package/packages/sdk/dist/events.js +305 -0
- package/packages/sdk/dist/explorer.d.ts +86 -0
- package/packages/sdk/dist/explorer.js +197 -0
- package/packages/sdk/dist/index.d.ts +61 -0
- package/packages/sdk/dist/index.js +216 -0
- package/packages/sdk/dist/instructions.d.ts +1125 -0
- package/packages/sdk/dist/instructions.js +1760 -0
- package/packages/sdk/dist/keys.d.ts +467 -0
- package/packages/sdk/dist/keys.js +799 -0
- package/packages/sdk/dist/logger.d.ts +10 -0
- package/packages/sdk/dist/logger.js +40 -0
- package/packages/sdk/dist/magicblock.d.ts +90 -0
- package/packages/sdk/dist/magicblock.js +164 -0
- package/packages/sdk/dist/merkle.d.ts +82 -0
- package/packages/sdk/dist/merkle.js +141 -0
- package/packages/sdk/dist/note.d.ts +348 -0
- package/packages/sdk/dist/note.js +483 -0
- package/packages/sdk/dist/pda.d.ts +160 -0
- package/packages/sdk/dist/pda.js +384 -0
- package/packages/sdk/dist/pool-state.d.ts +100 -0
- package/packages/sdk/dist/pool-state.js +126 -0
- package/packages/sdk/dist/poseidon.d.ts +53 -0
- package/packages/sdk/dist/poseidon.js +136 -0
- package/packages/sdk/dist/prover/index.d.ts +10 -0
- package/packages/sdk/dist/prover/index.js +10 -0
- package/packages/sdk/dist/prover/mobile.d.ts +45 -0
- package/packages/sdk/dist/prover/mobile.js +227 -0
- package/packages/sdk/dist/prover/web.d.ts +136 -0
- package/packages/sdk/dist/prover/web.js +597 -0
- package/packages/sdk/dist/psbt.d.ts +73 -0
- package/packages/sdk/dist/psbt.js +202 -0
- package/packages/sdk/dist/selective-disclosure.d.ts +181 -0
- package/packages/sdk/dist/selective-disclosure.js +172 -0
- package/packages/sdk/dist/sender-memo.d.ts +149 -0
- package/packages/sdk/dist/sender-memo.js +250 -0
- package/packages/sdk/dist/sns-resolver.d.ts +121 -0
- package/packages/sdk/dist/sns-resolver.js +229 -0
- package/packages/sdk/dist/solana/connection.d.ts +77 -0
- package/packages/sdk/dist/solana/connection.js +133 -0
- package/packages/sdk/dist/solana/priority-fee.d.ts +78 -0
- package/packages/sdk/dist/solana/priority-fee.js +137 -0
- package/packages/sdk/dist/stealth.d.ts +429 -0
- package/packages/sdk/dist/stealth.js +703 -0
- package/packages/sdk/dist/taproot.d.ts +172 -0
- package/packages/sdk/dist/taproot.js +494 -0
- package/packages/sdk/dist/token-registry.d.ts +96 -0
- package/packages/sdk/dist/token-registry.js +122 -0
- package/packages/sdk/dist/utils/encoding.d.ts +19 -0
- package/packages/sdk/dist/utils/encoding.js +29 -0
- package/packages/sdk/dist/vk-registry.d.ts +100 -0
- package/packages/sdk/dist/vk-registry.js +212 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PSBT builder for UTXOpia non-interactive deposits.
|
|
3
|
+
*
|
|
4
|
+
* Creates a Partially-Signed Bitcoin Transaction with:
|
|
5
|
+
* - Input(s): user's UTXOs (P2TR or P2WPKH)
|
|
6
|
+
* - Output 1: P2TR deposit (commitment-bound Taproot address)
|
|
7
|
+
* - Output 2: OP_RETURN (73 bytes: header || poolTag || ephemeralPub || npk)
|
|
8
|
+
* - Output 3: change back to user (if needed)
|
|
9
|
+
*
|
|
10
|
+
* Uses @scure/btc-signer for PSBT construction.
|
|
11
|
+
*/
|
|
12
|
+
import * as btc from "@scure/btc-signer";
|
|
13
|
+
import { hex } from "@scure/base";
|
|
14
|
+
import { DEPOSIT_OP_RETURN_SIZE, createOpReturnScriptFromPayload } from "./taproot";
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Constants
|
|
17
|
+
// =============================================================================
|
|
18
|
+
/** Dust limit for Bitcoin outputs (satoshis) */
|
|
19
|
+
const DUST_LIMIT = 546;
|
|
20
|
+
/** Estimated vbytes per P2TR key-path input */
|
|
21
|
+
const P2TR_INPUT_VBYTES = 58;
|
|
22
|
+
/** Estimated vbytes per P2WPKH input */
|
|
23
|
+
const P2WPKH_INPUT_VBYTES = 68;
|
|
24
|
+
/** Estimated vbytes for P2TR output */
|
|
25
|
+
const P2TR_OUTPUT_VBYTES = 43;
|
|
26
|
+
/** Estimated vbytes for OP_RETURN output (with compact deposit payload) */
|
|
27
|
+
const OP_RETURN_OUTPUT_VBYTES = 84; // 8 value + 1 script_len + 1 OP_RETURN + 1 push + 73 payload
|
|
28
|
+
/** Transaction overhead (version + locktime + segwit marker + input/output count) */
|
|
29
|
+
const TX_OVERHEAD_VBYTES = 11;
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Fee Estimation
|
|
32
|
+
// =============================================================================
|
|
33
|
+
/**
|
|
34
|
+
* Estimate the transaction fee for a deposit PSBT.
|
|
35
|
+
*/
|
|
36
|
+
export function estimateDepositFee(numInputs, feeRate, inputType = "p2tr", hasChange = true) {
|
|
37
|
+
const inputVbytes = inputType === "p2tr" ? P2TR_INPUT_VBYTES : P2WPKH_INPUT_VBYTES;
|
|
38
|
+
const outputCount = hasChange ? 3 : 2; // deposit + OP_RETURN + optional change
|
|
39
|
+
const vsize = TX_OVERHEAD_VBYTES +
|
|
40
|
+
numInputs * inputVbytes +
|
|
41
|
+
P2TR_OUTPUT_VBYTES + // deposit output
|
|
42
|
+
OP_RETURN_OUTPUT_VBYTES + // OP_RETURN output
|
|
43
|
+
(hasChange ? P2TR_OUTPUT_VBYTES : 0); // change output
|
|
44
|
+
return Math.ceil(vsize * feeRate);
|
|
45
|
+
}
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// PSBT Builder
|
|
48
|
+
// =============================================================================
|
|
49
|
+
/**
|
|
50
|
+
* Build a deposit PSBT with OP_RETURN for non-interactive stealth deposits.
|
|
51
|
+
*
|
|
52
|
+
* The PSBT is unsigned — it must be signed by the user's wallet (e.g. via sats-connect).
|
|
53
|
+
*/
|
|
54
|
+
export function buildDepositPsbt(params) {
|
|
55
|
+
const { senderUtxos, depositAddress, depositAmountSats, opReturnPayload, changeAddress, feeRate, network = "testnet", } = params;
|
|
56
|
+
if (senderUtxos.length === 0) {
|
|
57
|
+
throw new Error("No UTXOs provided");
|
|
58
|
+
}
|
|
59
|
+
if (depositAmountSats < DUST_LIMIT) {
|
|
60
|
+
throw new Error(`Deposit amount ${depositAmountSats} is below dust limit ${DUST_LIMIT}`);
|
|
61
|
+
}
|
|
62
|
+
if (opReturnPayload.length !== DEPOSIT_OP_RETURN_SIZE) {
|
|
63
|
+
throw new Error(`OP_RETURN payload must be ${DEPOSIT_OP_RETURN_SIZE} bytes, got ${opReturnPayload.length}`);
|
|
64
|
+
}
|
|
65
|
+
const btcNetwork = network === "mainnet" ? btc.NETWORK : btc.TEST_NETWORK;
|
|
66
|
+
// Calculate total input value
|
|
67
|
+
const totalInput = senderUtxos.reduce((sum, u) => sum + u.value, 0);
|
|
68
|
+
// Detect input type from first UTXO
|
|
69
|
+
const firstScript = hex.decode(senderUtxos[0].scriptPubkeyHex);
|
|
70
|
+
const inputType = firstScript[0] === 0x51 ? "p2tr" : "p2wpkh";
|
|
71
|
+
// Estimate fee with change
|
|
72
|
+
const feeWithChange = estimateDepositFee(senderUtxos.length, feeRate, inputType, true);
|
|
73
|
+
const changeAmount = totalInput - depositAmountSats - feeWithChange;
|
|
74
|
+
// Check if we have enough funds
|
|
75
|
+
const feeWithoutChange = estimateDepositFee(senderUtxos.length, feeRate, inputType, false);
|
|
76
|
+
if (totalInput < depositAmountSats + feeWithoutChange) {
|
|
77
|
+
throw new Error(`Insufficient funds: have ${totalInput} sats, need ${depositAmountSats + feeWithoutChange} sats (including fee)`);
|
|
78
|
+
}
|
|
79
|
+
const hasChange = changeAmount > DUST_LIMIT;
|
|
80
|
+
const actualFee = hasChange ? feeWithChange : totalInput - depositAmountSats;
|
|
81
|
+
// Build the transaction using @scure/btc-signer
|
|
82
|
+
// allowUnknownOutputs is required for the OP_RETURN output script
|
|
83
|
+
const tx = new btc.Transaction({ allowUnknownOutputs: true });
|
|
84
|
+
// Add inputs
|
|
85
|
+
for (const utxo of senderUtxos) {
|
|
86
|
+
const scriptPubkey = hex.decode(utxo.scriptPubkeyHex);
|
|
87
|
+
if (scriptPubkey[0] === 0x51 && scriptPubkey.length === 34) {
|
|
88
|
+
// P2TR input
|
|
89
|
+
tx.addInput({
|
|
90
|
+
txid: utxo.txid,
|
|
91
|
+
index: utxo.vout,
|
|
92
|
+
witnessUtxo: {
|
|
93
|
+
script: scriptPubkey,
|
|
94
|
+
amount: BigInt(utxo.value),
|
|
95
|
+
},
|
|
96
|
+
tapInternalKey: scriptPubkey.slice(2), // x-only pubkey from OP_1 <32 bytes>
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
else if (scriptPubkey[0] === 0x00 && scriptPubkey.length === 22) {
|
|
100
|
+
// P2WPKH input
|
|
101
|
+
tx.addInput({
|
|
102
|
+
txid: utxo.txid,
|
|
103
|
+
index: utxo.vout,
|
|
104
|
+
witnessUtxo: {
|
|
105
|
+
script: scriptPubkey,
|
|
106
|
+
amount: BigInt(utxo.value),
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
throw new Error(`Unsupported input script type for UTXO ${utxo.txid}:${utxo.vout}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Output 1: P2TR deposit
|
|
115
|
+
tx.addOutputAddress(depositAddress, BigInt(depositAmountSats), btcNetwork);
|
|
116
|
+
// Output 2: OP_RETURN with compact deposit payload.
|
|
117
|
+
const opReturnScript = createOpReturnScriptFromPayload(opReturnPayload);
|
|
118
|
+
tx.addOutput({
|
|
119
|
+
script: opReturnScript,
|
|
120
|
+
amount: 0n,
|
|
121
|
+
});
|
|
122
|
+
// Output 3: Change (if above dust)
|
|
123
|
+
if (hasChange) {
|
|
124
|
+
tx.addOutputAddress(changeAddress, BigInt(changeAmount), btcNetwork);
|
|
125
|
+
}
|
|
126
|
+
// Extract PSBT
|
|
127
|
+
const psbtBytes = tx.toPSBT();
|
|
128
|
+
const psbtHex = hex.encode(psbtBytes);
|
|
129
|
+
const psbtBase64 = btoa(String.fromCharCode(...psbtBytes));
|
|
130
|
+
return {
|
|
131
|
+
psbtBase64,
|
|
132
|
+
psbtHex,
|
|
133
|
+
estimatedFee: actualFee,
|
|
134
|
+
totalInput,
|
|
135
|
+
changeAmount: hasChange ? changeAmount : 0,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Fetch UTXOs for an address from mempool.space API.
|
|
140
|
+
*/
|
|
141
|
+
export async function fetchUtxos(address, network = "testnet") {
|
|
142
|
+
const baseUrl = network === "mainnet"
|
|
143
|
+
? "https://mempool.space/api"
|
|
144
|
+
: network === "signet"
|
|
145
|
+
? "https://mempool.space/signet/api"
|
|
146
|
+
: "https://mempool.space/testnet/api";
|
|
147
|
+
// Fetch UTXOs
|
|
148
|
+
const utxoRes = await fetch(`${baseUrl}/address/${address}/utxo`);
|
|
149
|
+
if (!utxoRes.ok) {
|
|
150
|
+
throw new Error(`Failed to fetch UTXOs: ${utxoRes.status} ${utxoRes.statusText}`);
|
|
151
|
+
}
|
|
152
|
+
const utxos = await utxoRes.json();
|
|
153
|
+
// We need scriptPubkey for each UTXO. Fetch from the tx details.
|
|
154
|
+
// For efficiency, batch unique txids.
|
|
155
|
+
const txidSet = new Set(utxos.map((u) => u.txid));
|
|
156
|
+
const txCache = new Map();
|
|
157
|
+
await Promise.all([...txidSet].map(async (txid) => {
|
|
158
|
+
const txRes = await fetch(`${baseUrl}/tx/${txid}`);
|
|
159
|
+
if (txRes.ok) {
|
|
160
|
+
txCache.set(txid, await txRes.json());
|
|
161
|
+
}
|
|
162
|
+
}));
|
|
163
|
+
return utxos
|
|
164
|
+
.filter((u) => u.status.confirmed) // Only confirmed UTXOs
|
|
165
|
+
.map((u) => {
|
|
166
|
+
const tx = txCache.get(u.txid);
|
|
167
|
+
const output = tx?.vout?.[u.vout];
|
|
168
|
+
const scriptPubkeyHex = output?.scriptpubkey ?? "";
|
|
169
|
+
return {
|
|
170
|
+
txid: u.txid,
|
|
171
|
+
vout: u.vout,
|
|
172
|
+
value: u.value,
|
|
173
|
+
scriptPubkeyHex,
|
|
174
|
+
};
|
|
175
|
+
})
|
|
176
|
+
.filter((u) => u.scriptPubkeyHex.length > 0);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Select UTXOs to cover the target amount + estimated fee.
|
|
180
|
+
* Simple greedy algorithm: sort descending by value, take until covered.
|
|
181
|
+
*/
|
|
182
|
+
export function selectUtxos(utxos, targetSats, feeRate) {
|
|
183
|
+
// Sort descending by value
|
|
184
|
+
const sorted = [...utxos].sort((a, b) => b.value - a.value);
|
|
185
|
+
const selected = [];
|
|
186
|
+
let total = 0;
|
|
187
|
+
for (const utxo of sorted) {
|
|
188
|
+
selected.push(utxo);
|
|
189
|
+
total += utxo.value;
|
|
190
|
+
// Estimate fee for current selection
|
|
191
|
+
const fee = estimateDepositFee(selected.length, feeRate);
|
|
192
|
+
if (total >= targetSats + fee) {
|
|
193
|
+
return selected;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Not enough funds
|
|
197
|
+
const fee = estimateDepositFee(selected.length, feeRate);
|
|
198
|
+
if (total < targetSats + fee) {
|
|
199
|
+
throw new Error(`Insufficient funds: have ${total} sats, need ${targetSats + fee} sats`);
|
|
200
|
+
}
|
|
201
|
+
return selected;
|
|
202
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selective ZK Disclosure (Phase 4 skeleton)
|
|
3
|
+
*
|
|
4
|
+
* Targeted ZK proofs that reveal a single fact about the prover's notes
|
|
5
|
+
* without handing over a viewing key. Two flavors:
|
|
6
|
+
*
|
|
7
|
+
* 1. Ownership proof — "I own commitment X, amount ≥ Y."
|
|
8
|
+
* Useful for: proof-of-funds to a regulator, dispute resolution,
|
|
9
|
+
* challenge-response with a counterparty.
|
|
10
|
+
*
|
|
11
|
+
* 2. Range-sum proof — "Σ amount_i over a slot range ≤ Z" across a
|
|
12
|
+
* caller-provided set of leaf indices, with a check that I have
|
|
13
|
+
* *committed* to the full set via a Merkle commitment over the set.
|
|
14
|
+
*
|
|
15
|
+
* Status (2026-05-13): types frozen, no live prover. Circuits for both
|
|
16
|
+
* (`ownership.circom`, `range_sum.circom`) need design + trusted setup before
|
|
17
|
+
* runtime can do real work. Until then both generator functions throw.
|
|
18
|
+
*
|
|
19
|
+
* Design notes:
|
|
20
|
+
*
|
|
21
|
+
* Ownership proof. Algorithm sketch:
|
|
22
|
+
* - Inputs (private): spendingPrivScalar, randomIn, valueIn, merkleProof
|
|
23
|
+
* - Inputs (public): commitment, merkleRoot, threshold (Y), token
|
|
24
|
+
* - Constraints:
|
|
25
|
+
* (a) commitment == Poseidon(NPK, token, valueIn) where
|
|
26
|
+
* NPK = Poseidon(MPK, randomIn) and MPK = Poseidon(spendingPub, nk)
|
|
27
|
+
* (b) commitment ∈ tree(merkleRoot)
|
|
28
|
+
* (c) valueIn >= threshold (range check)
|
|
29
|
+
* - The proof produces a *non-spending* witness: no nullifier is emitted,
|
|
30
|
+
* so generating the proof doesn't burn the note.
|
|
31
|
+
*
|
|
32
|
+
* Range-sum proof. Algorithm sketch:
|
|
33
|
+
* - Inputs (private): for each note i: randomIn[i], valueIn[i], merkleProof[i]
|
|
34
|
+
* - Inputs (public): leafIndices[i], merkleRoot, ceiling (Z), token,
|
|
35
|
+
* attestation = Poseidon(leafIndices, viewerNonce) — a binding so the
|
|
36
|
+
* verifier knows the prover committed to *this exact* set of leaves
|
|
37
|
+
* - Constraints:
|
|
38
|
+
* (a) each commitment ∈ tree(merkleRoot)
|
|
39
|
+
* (b) each commitment == Poseidon(NPK_i, token, valueIn[i])
|
|
40
|
+
* (c) Σ valueIn[i] <= ceiling
|
|
41
|
+
* (d) attestation matches
|
|
42
|
+
* - Coverage check (the hard part: proving the prover didn't *omit* notes
|
|
43
|
+
* in the slot range) is enforced by having the verifier independently
|
|
44
|
+
* compute attestation = Poseidon(everyKnownLeaf, viewerNonce) and
|
|
45
|
+
* compare. The verifier learns the leaf set from the public viewing key
|
|
46
|
+
* handed over for that purpose.
|
|
47
|
+
*
|
|
48
|
+
* The viewing-key-coverage dependency means range-sum is most useful when
|
|
49
|
+
* paired with the auditor flow from Phase 1 — a one-shot disclosure that
|
|
50
|
+
* proves a property over the set the auditor already verified.
|
|
51
|
+
*/
|
|
52
|
+
import type { ProofData } from "./prover/web";
|
|
53
|
+
export interface OwnershipProofInputs {
|
|
54
|
+
/** Witness data for the note. */
|
|
55
|
+
spendingPrivScalar: bigint;
|
|
56
|
+
nullifyingKey: bigint;
|
|
57
|
+
randomIn: bigint;
|
|
58
|
+
valueIn: bigint;
|
|
59
|
+
pathElements: bigint[];
|
|
60
|
+
pathIndices: number[];
|
|
61
|
+
/** Public commitments the prover is claiming. */
|
|
62
|
+
commitment: bigint;
|
|
63
|
+
merkleRoot: bigint;
|
|
64
|
+
/** Minimum amount the prover asserts the note holds. */
|
|
65
|
+
threshold: bigint;
|
|
66
|
+
tokenId: bigint;
|
|
67
|
+
}
|
|
68
|
+
export interface OwnershipPublicInputs {
|
|
69
|
+
commitment: bigint;
|
|
70
|
+
merkleRoot: bigint;
|
|
71
|
+
threshold: bigint;
|
|
72
|
+
tokenId: bigint;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Generate an ownership ZK proof.
|
|
76
|
+
*
|
|
77
|
+
* Calls into the generic snarkjs prover with the `ownership` circuit.
|
|
78
|
+
* Requires the compiled wasm + zkey under `<circuitBasePath>/ownership/`.
|
|
79
|
+
*
|
|
80
|
+
* Public inputs: [commitment, merkleRoot, threshold, tokenId]
|
|
81
|
+
*/
|
|
82
|
+
export declare function generateOwnershipProof(inputs: OwnershipProofInputs): Promise<ProofData>;
|
|
83
|
+
export interface RangeSumProofInputs {
|
|
84
|
+
/** One entry per note in the prover's claimed set. */
|
|
85
|
+
notes: ReadonlyArray<{
|
|
86
|
+
randomIn: bigint;
|
|
87
|
+
valueIn: bigint;
|
|
88
|
+
pathElements: bigint[];
|
|
89
|
+
/** @deprecated No longer used: the circuit derives path bits from leafIndex. */
|
|
90
|
+
pathIndices?: number[];
|
|
91
|
+
commitment: bigint;
|
|
92
|
+
leafIndex: number;
|
|
93
|
+
}>;
|
|
94
|
+
/** Common shared data. */
|
|
95
|
+
spendingPrivScalar: bigint;
|
|
96
|
+
nullifyingKey: bigint;
|
|
97
|
+
merkleRoot: bigint;
|
|
98
|
+
/** Upper bound on the sum the prover asserts. */
|
|
99
|
+
ceiling: bigint;
|
|
100
|
+
tokenId: bigint;
|
|
101
|
+
/** Salt the verifier supplied, binding the proof to this verification round. */
|
|
102
|
+
viewerNonce: bigint;
|
|
103
|
+
/** Poseidon(leafIndices ++ [viewerNonce]) — must match the public input. */
|
|
104
|
+
attestation: bigint;
|
|
105
|
+
}
|
|
106
|
+
export interface RangeSumPublicInputs {
|
|
107
|
+
leafIndices: number[];
|
|
108
|
+
merkleRoot: bigint;
|
|
109
|
+
ceiling: bigint;
|
|
110
|
+
tokenId: bigint;
|
|
111
|
+
/** Poseidon(leafIndices ++ [viewerNonce]) committed to by the prover. */
|
|
112
|
+
attestation: bigint;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* How the variant computes `attestation` from `(leafIndices, viewerNonce)`.
|
|
116
|
+
*
|
|
117
|
+
* - "flat" — `Poseidon(leafIndices ++ [viewerNonce])`. Cheap; requires
|
|
118
|
+
* N+1 ≤ 16 because circomlib's Poseidon caps at arity 16.
|
|
119
|
+
* - "chunked" — `Poseidon(Poseidon(leafIndices[0..N/2]),
|
|
120
|
+
* Poseidon(leafIndices[N/2..N]),
|
|
121
|
+
* viewerNonce)`. Needed at N=16 where the flat
|
|
122
|
+
* form would require Poseidon(17). Only one nesting level
|
|
123
|
+
* ships today (N=16); deeper variants would extend the
|
|
124
|
+
* same pattern.
|
|
125
|
+
*/
|
|
126
|
+
export type RangeSumAttestationStyle = "flat" | "chunked";
|
|
127
|
+
/**
|
|
128
|
+
* Compiled range-sum variants and the cardinality each handles. Add new
|
|
129
|
+
* entries here when you build a new sibling circuit. The `circuit` field
|
|
130
|
+
* must match the directory name under `circuits/build/`.
|
|
131
|
+
*/
|
|
132
|
+
export declare const RANGE_SUM_VARIANTS: readonly [{
|
|
133
|
+
readonly n: 4;
|
|
134
|
+
readonly circuit: "range_sum_4";
|
|
135
|
+
readonly attestation: "flat";
|
|
136
|
+
}, {
|
|
137
|
+
readonly n: 8;
|
|
138
|
+
readonly circuit: "range_sum";
|
|
139
|
+
readonly attestation: "flat";
|
|
140
|
+
}, {
|
|
141
|
+
readonly n: 16;
|
|
142
|
+
readonly circuit: "range_sum_16";
|
|
143
|
+
readonly attestation: "chunked";
|
|
144
|
+
}];
|
|
145
|
+
/** Number of notes accepted by each compiled variant. */
|
|
146
|
+
export declare const RANGE_SUM_SIZES: (8 | 16 | 4)[];
|
|
147
|
+
/**
|
|
148
|
+
* @deprecated Prefer `RANGE_SUM_VARIANTS` / `pickRangeSumVariant`. Retained
|
|
149
|
+
* for back-compat with older callers that assumed a single N.
|
|
150
|
+
*/
|
|
151
|
+
export declare const RANGE_SUM_N = 8;
|
|
152
|
+
/** Look up the compiled circuit name for a given note cardinality. */
|
|
153
|
+
export declare function pickRangeSumVariant(n: number): typeof RANGE_SUM_VARIANTS[number];
|
|
154
|
+
/**
|
|
155
|
+
* Compute the range-sum attestation public input the way each compiled
|
|
156
|
+
* circuit expects. SDK + CLI must agree on this exactly or the verifier
|
|
157
|
+
* rejects the proof.
|
|
158
|
+
*
|
|
159
|
+
* - flat: Poseidon(leafIndices ++ [viewerNonce])
|
|
160
|
+
* - chunked: Poseidon(Poseidon(leafIndices[0..N/2]),
|
|
161
|
+
* Poseidon(leafIndices[N/2..N]),
|
|
162
|
+
* viewerNonce)
|
|
163
|
+
*
|
|
164
|
+
* `style` defaults to the cardinality's compiled variant; pass it
|
|
165
|
+
* explicitly if you're computing the value without going through
|
|
166
|
+
* `generateRangeSumProof` (e.g. inside a CLI that lays out witness data
|
|
167
|
+
* up front).
|
|
168
|
+
*/
|
|
169
|
+
export declare function computeRangeSumAttestation(leafIndices: ReadonlyArray<number | bigint>, viewerNonce: bigint, style?: RangeSumAttestationStyle): Promise<bigint>;
|
|
170
|
+
/**
|
|
171
|
+
* Generate a range-sum ZK proof. The variant is picked automatically from
|
|
172
|
+
* `inputs.notes.length` — compile + register new variants in
|
|
173
|
+
* `RANGE_SUM_VARIANTS` to extend the supported cardinalities.
|
|
174
|
+
*
|
|
175
|
+
* Calls into the generic snarkjs prover with the matching circuit.
|
|
176
|
+
* Requires the compiled wasm + zkey under
|
|
177
|
+
* `<circuitBasePath>/<variant.circuit>/`.
|
|
178
|
+
*
|
|
179
|
+
* Public inputs: [leafIndices(N), merkleRoot, ceiling, token, attestation]
|
|
180
|
+
*/
|
|
181
|
+
export declare function generateRangeSumProof(inputs: RangeSumProofInputs): Promise<ProofData>;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selective ZK Disclosure (Phase 4 skeleton)
|
|
3
|
+
*
|
|
4
|
+
* Targeted ZK proofs that reveal a single fact about the prover's notes
|
|
5
|
+
* without handing over a viewing key. Two flavors:
|
|
6
|
+
*
|
|
7
|
+
* 1. Ownership proof — "I own commitment X, amount ≥ Y."
|
|
8
|
+
* Useful for: proof-of-funds to a regulator, dispute resolution,
|
|
9
|
+
* challenge-response with a counterparty.
|
|
10
|
+
*
|
|
11
|
+
* 2. Range-sum proof — "Σ amount_i over a slot range ≤ Z" across a
|
|
12
|
+
* caller-provided set of leaf indices, with a check that I have
|
|
13
|
+
* *committed* to the full set via a Merkle commitment over the set.
|
|
14
|
+
*
|
|
15
|
+
* Status (2026-05-13): types frozen, no live prover. Circuits for both
|
|
16
|
+
* (`ownership.circom`, `range_sum.circom`) need design + trusted setup before
|
|
17
|
+
* runtime can do real work. Until then both generator functions throw.
|
|
18
|
+
*
|
|
19
|
+
* Design notes:
|
|
20
|
+
*
|
|
21
|
+
* Ownership proof. Algorithm sketch:
|
|
22
|
+
* - Inputs (private): spendingPrivScalar, randomIn, valueIn, merkleProof
|
|
23
|
+
* - Inputs (public): commitment, merkleRoot, threshold (Y), token
|
|
24
|
+
* - Constraints:
|
|
25
|
+
* (a) commitment == Poseidon(NPK, token, valueIn) where
|
|
26
|
+
* NPK = Poseidon(MPK, randomIn) and MPK = Poseidon(spendingPub, nk)
|
|
27
|
+
* (b) commitment ∈ tree(merkleRoot)
|
|
28
|
+
* (c) valueIn >= threshold (range check)
|
|
29
|
+
* - The proof produces a *non-spending* witness: no nullifier is emitted,
|
|
30
|
+
* so generating the proof doesn't burn the note.
|
|
31
|
+
*
|
|
32
|
+
* Range-sum proof. Algorithm sketch:
|
|
33
|
+
* - Inputs (private): for each note i: randomIn[i], valueIn[i], merkleProof[i]
|
|
34
|
+
* - Inputs (public): leafIndices[i], merkleRoot, ceiling (Z), token,
|
|
35
|
+
* attestation = Poseidon(leafIndices, viewerNonce) — a binding so the
|
|
36
|
+
* verifier knows the prover committed to *this exact* set of leaves
|
|
37
|
+
* - Constraints:
|
|
38
|
+
* (a) each commitment ∈ tree(merkleRoot)
|
|
39
|
+
* (b) each commitment == Poseidon(NPK_i, token, valueIn[i])
|
|
40
|
+
* (c) Σ valueIn[i] <= ceiling
|
|
41
|
+
* (d) attestation matches
|
|
42
|
+
* - Coverage check (the hard part: proving the prover didn't *omit* notes
|
|
43
|
+
* in the slot range) is enforced by having the verifier independently
|
|
44
|
+
* compute attestation = Poseidon(everyKnownLeaf, viewerNonce) and
|
|
45
|
+
* compare. The verifier learns the leaf set from the public viewing key
|
|
46
|
+
* handed over for that purpose.
|
|
47
|
+
*
|
|
48
|
+
* The viewing-key-coverage dependency means range-sum is most useful when
|
|
49
|
+
* paired with the auditor flow from Phase 1 — a one-shot disclosure that
|
|
50
|
+
* proves a property over the set the auditor already verified.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* Generate an ownership ZK proof.
|
|
54
|
+
*
|
|
55
|
+
* Calls into the generic snarkjs prover with the `ownership` circuit.
|
|
56
|
+
* Requires the compiled wasm + zkey under `<circuitBasePath>/ownership/`.
|
|
57
|
+
*
|
|
58
|
+
* Public inputs: [commitment, merkleRoot, threshold, tokenId]
|
|
59
|
+
*/
|
|
60
|
+
export async function generateOwnershipProof(inputs) {
|
|
61
|
+
const { generateGenericGroth16Proof } = await import("./prover/web");
|
|
62
|
+
return generateGenericGroth16Proof("ownership", {
|
|
63
|
+
commitment: inputs.commitment.toString(),
|
|
64
|
+
merkleRoot: inputs.merkleRoot.toString(),
|
|
65
|
+
threshold: inputs.threshold.toString(),
|
|
66
|
+
token: inputs.tokenId.toString(),
|
|
67
|
+
spendingPrivScalar: inputs.spendingPrivScalar.toString(),
|
|
68
|
+
randomIn: inputs.randomIn.toString(),
|
|
69
|
+
valueIn: inputs.valueIn.toString(),
|
|
70
|
+
pathElements: inputs.pathElements.map((e) => e.toString()),
|
|
71
|
+
pathIndices: inputs.pathIndices,
|
|
72
|
+
nullifyingKey: inputs.nullifyingKey.toString(),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Compiled range-sum variants and the cardinality each handles. Add new
|
|
77
|
+
* entries here when you build a new sibling circuit. The `circuit` field
|
|
78
|
+
* must match the directory name under `circuits/build/`.
|
|
79
|
+
*/
|
|
80
|
+
export const RANGE_SUM_VARIANTS = [
|
|
81
|
+
{ n: 4, circuit: "range_sum_4", attestation: "flat" },
|
|
82
|
+
{ n: 8, circuit: "range_sum", attestation: "flat" },
|
|
83
|
+
{ n: 16, circuit: "range_sum_16", attestation: "chunked" },
|
|
84
|
+
];
|
|
85
|
+
/** Number of notes accepted by each compiled variant. */
|
|
86
|
+
export const RANGE_SUM_SIZES = RANGE_SUM_VARIANTS.map((v) => v.n);
|
|
87
|
+
/**
|
|
88
|
+
* @deprecated Prefer `RANGE_SUM_VARIANTS` / `pickRangeSumVariant`. Retained
|
|
89
|
+
* for back-compat with older callers that assumed a single N.
|
|
90
|
+
*/
|
|
91
|
+
export const RANGE_SUM_N = 8;
|
|
92
|
+
/** Look up the compiled circuit name for a given note cardinality. */
|
|
93
|
+
export function pickRangeSumVariant(n) {
|
|
94
|
+
const v = RANGE_SUM_VARIANTS.find((x) => x.n === n);
|
|
95
|
+
if (!v) {
|
|
96
|
+
throw new Error(`range_sum has no compiled variant for N=${n}. Compiled variants: ` +
|
|
97
|
+
RANGE_SUM_SIZES.join(", ") +
|
|
98
|
+
". Pad with zero-value notes or compile a new variant " +
|
|
99
|
+
"(see circuits/scripts/build-aux.sh).");
|
|
100
|
+
}
|
|
101
|
+
return v;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Compute the range-sum attestation public input the way each compiled
|
|
105
|
+
* circuit expects. SDK + CLI must agree on this exactly or the verifier
|
|
106
|
+
* rejects the proof.
|
|
107
|
+
*
|
|
108
|
+
* - flat: Poseidon(leafIndices ++ [viewerNonce])
|
|
109
|
+
* - chunked: Poseidon(Poseidon(leafIndices[0..N/2]),
|
|
110
|
+
* Poseidon(leafIndices[N/2..N]),
|
|
111
|
+
* viewerNonce)
|
|
112
|
+
*
|
|
113
|
+
* `style` defaults to the cardinality's compiled variant; pass it
|
|
114
|
+
* explicitly if you're computing the value without going through
|
|
115
|
+
* `generateRangeSumProof` (e.g. inside a CLI that lays out witness data
|
|
116
|
+
* up front).
|
|
117
|
+
*/
|
|
118
|
+
export async function computeRangeSumAttestation(leafIndices, viewerNonce, style) {
|
|
119
|
+
// Only consult the variants registry when the caller didn't pick a style
|
|
120
|
+
// explicitly. The explicit-style path is for tests / parity checks that
|
|
121
|
+
// want to compute the hash without first compiling a variant.
|
|
122
|
+
const resolvedStyle = style ?? pickRangeSumVariant(leafIndices.length).attestation;
|
|
123
|
+
// circomlibjs's Poseidon caps at the same arity (16) as the circom-side
|
|
124
|
+
// hash. Importing the heavy circomlibjs module lazily keeps the SDK
|
|
125
|
+
// bundle slim for callers that never compute an attestation.
|
|
126
|
+
const { buildPoseidon } = await import("circomlibjs");
|
|
127
|
+
const poseidon = (await buildPoseidon());
|
|
128
|
+
const F = poseidon.F;
|
|
129
|
+
const indicesAsField = leafIndices.map((i) => F.e(BigInt(i)));
|
|
130
|
+
if (resolvedStyle === "flat") {
|
|
131
|
+
return F.toObject(poseidon([...indicesAsField, F.e(viewerNonce)]));
|
|
132
|
+
}
|
|
133
|
+
// chunked: split in half, hash each half, then hash the two digests + nonce.
|
|
134
|
+
const half = leafIndices.length / 2;
|
|
135
|
+
if (!Number.isInteger(half)) {
|
|
136
|
+
throw new Error(`chunked attestation requires an even cardinality; got N=${leafIndices.length}`);
|
|
137
|
+
}
|
|
138
|
+
const chunk1 = poseidon(indicesAsField.slice(0, half));
|
|
139
|
+
const chunk2 = poseidon(indicesAsField.slice(half));
|
|
140
|
+
return F.toObject(poseidon([chunk1, chunk2, F.e(viewerNonce)]));
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Generate a range-sum ZK proof. The variant is picked automatically from
|
|
144
|
+
* `inputs.notes.length` — compile + register new variants in
|
|
145
|
+
* `RANGE_SUM_VARIANTS` to extend the supported cardinalities.
|
|
146
|
+
*
|
|
147
|
+
* Calls into the generic snarkjs prover with the matching circuit.
|
|
148
|
+
* Requires the compiled wasm + zkey under
|
|
149
|
+
* `<circuitBasePath>/<variant.circuit>/`.
|
|
150
|
+
*
|
|
151
|
+
* Public inputs: [leafIndices(N), merkleRoot, ceiling, token, attestation]
|
|
152
|
+
*/
|
|
153
|
+
export async function generateRangeSumProof(inputs) {
|
|
154
|
+
const variant = pickRangeSumVariant(inputs.notes.length);
|
|
155
|
+
const { generateGenericGroth16Proof } = await import("./prover/web");
|
|
156
|
+
return generateGenericGroth16Proof(variant.circuit, {
|
|
157
|
+
leafIndices: inputs.notes.map((n) => n.leafIndex),
|
|
158
|
+
merkleRoot: inputs.merkleRoot.toString(),
|
|
159
|
+
ceiling: inputs.ceiling.toString(),
|
|
160
|
+
token: inputs.tokenId.toString(),
|
|
161
|
+
attestation: inputs.attestation.toString(),
|
|
162
|
+
spendingPrivScalar: inputs.spendingPrivScalar.toString(),
|
|
163
|
+
nullifyingKey: inputs.nullifyingKey.toString(),
|
|
164
|
+
randomIn: inputs.notes.map((n) => n.randomIn.toString()),
|
|
165
|
+
valueIn: inputs.notes.map((n) => n.valueIn.toString()),
|
|
166
|
+
pathElements: inputs.notes.map((n) => n.pathElements.map((e) => e.toString())),
|
|
167
|
+
// pathIndices is derived in-circuit from the public leafIndices (Num2Bits),
|
|
168
|
+
// so it is no longer passed as a witness input.
|
|
169
|
+
viewerNonce: inputs.viewerNonce.toString(),
|
|
170
|
+
commitmentsIn: inputs.notes.map((n) => n.commitment.toString()),
|
|
171
|
+
});
|
|
172
|
+
}
|