@frontiercompute/zcash-ika 0.1.0 → 0.3.0

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.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Zcash v5 transparent transaction builder with ZIP 244 sighash.
3
+ *
4
+ * Builds P2PKH transactions for MPC-signed transparent spends.
5
+ * The signing itself happens via Ika dWallet (secp256k1 ECDSA).
6
+ * This module handles everything else: UTXO fetch, TX structure,
7
+ * sighash computation, signature attachment, and broadcast.
8
+ */
9
+ export declare const BRANCH_ID: {
10
+ readonly NU5: 3268858036;
11
+ readonly NU6: 3370586197;
12
+ };
13
+ export interface UTXO {
14
+ txid: string;
15
+ outputIndex: number;
16
+ script: string;
17
+ satoshis: number;
18
+ }
19
+ export interface TxOutput {
20
+ address: string;
21
+ amount: number;
22
+ }
23
+ /**
24
+ * Fetch UTXOs for a transparent address from Zebra RPC.
25
+ * Uses getaddressutxos (requires Zebra with -indexer flag).
26
+ */
27
+ export declare function fetchUTXOs(zebraRpcUrl: string, tAddress: string): Promise<UTXO[]>;
28
+ /**
29
+ * Select UTXOs to cover the target amount + fee.
30
+ * Simple largest-first selection. Returns selected UTXOs and total value.
31
+ */
32
+ export declare function selectUTXOs(utxos: UTXO[], targetAmount: number, fee: number): {
33
+ selected: UTXO[];
34
+ totalInput: number;
35
+ };
36
+ /**
37
+ * Build an unsigned Zcash v5 transparent transaction.
38
+ *
39
+ * Returns the unsigned serialized TX and per-input sighashes
40
+ * that need to be signed via MPC.
41
+ */
42
+ export declare function buildUnsignedTx(utxos: UTXO[], recipient: string, amount: number, fee: number | undefined, changeAddress: string, branchId?: number): {
43
+ unsignedTx: Buffer;
44
+ sighashes: Buffer[];
45
+ txid: Buffer;
46
+ };
47
+ /**
48
+ * Attach MPC signatures to an unsigned transaction.
49
+ *
50
+ * Takes the original UTXO list (to reconstruct inputs/outputs),
51
+ * DER-encoded signatures from MPC, and the compressed pubkey.
52
+ * Returns hex-encoded signed transaction ready for broadcast.
53
+ */
54
+ export declare function attachSignatures(utxos: UTXO[], recipient: string, amount: number, fee: number, changeAddress: string, signatures: Buffer[], pubkey: Buffer, branchId?: number): string;
55
+ /**
56
+ * Broadcast a signed transaction via Zebra RPC.
57
+ * Returns the txid on success.
58
+ */
59
+ export declare function broadcastTx(zebraRpcUrl: string, txHex: string): Promise<string>;
60
+ /**
61
+ * Estimate fee for a transparent P2PKH transaction.
62
+ *
63
+ * ZIP 317 marginal fee: max(grace_actions, logical_actions) * 5000
64
+ * For simple P2PKH: 1 input + 2 outputs = 2 logical actions = 10000 zatoshis
65
+ * Each additional input adds 1 logical action = +5000 zatoshis
66
+ */
67
+ export declare function estimateFee(numInputs: number, numOutputs: number): number;
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Zcash v5 transparent transaction builder with ZIP 244 sighash.
3
+ *
4
+ * Builds P2PKH transactions for MPC-signed transparent spends.
5
+ * The signing itself happens via Ika dWallet (secp256k1 ECDSA).
6
+ * This module handles everything else: UTXO fetch, TX structure,
7
+ * sighash computation, signature attachment, and broadcast.
8
+ */
9
+ import blakejs from "blakejs";
10
+ const { blake2bInit, blake2bUpdate, blake2bFinal } = blakejs;
11
+ import { createHash } from "node:crypto";
12
+ // Zcash v5 transaction constants
13
+ const TX_VERSION = 5;
14
+ const TX_VERSION_GROUP_ID = 0x26a7270a;
15
+ // Consensus branch IDs
16
+ export const BRANCH_ID = {
17
+ NU5: 0xc2d6d0b4,
18
+ NU6: 0xc8e71055,
19
+ };
20
+ // SIGHASH flags
21
+ const SIGHASH_ALL = 0x01;
22
+ // Script opcodes for P2PKH
23
+ const OP_DUP = 0x76;
24
+ const OP_HASH160 = 0xa9;
25
+ const OP_EQUALVERIFY = 0x88;
26
+ const OP_CHECKSIG = 0xac;
27
+ // Zcash t-address version prefixes (for decoding)
28
+ const T_ADDR_VERSIONS = {
29
+ "1cb8": { mainnet: true }, // t1...
30
+ "1d25": { mainnet: false }, // tm...
31
+ };
32
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
33
+ function sha256(data) {
34
+ return createHash("sha256").update(data).digest();
35
+ }
36
+ function hash160(data) {
37
+ return createHash("ripemd160").update(sha256(data)).digest();
38
+ }
39
+ // BLAKE2b-256 with personalization
40
+ // blakejs types don't expose the personal param on blake2bInit, but the JS does
41
+ function blake2b256(data, personal) {
42
+ const ctx = blake2bInit(32, undefined, undefined, personal);
43
+ blake2bUpdate(ctx, data);
44
+ return Buffer.from(blake2bFinal(ctx));
45
+ }
46
+ // Write uint32 little-endian into buffer
47
+ function writeU32LE(buf, value, offset) {
48
+ buf.writeUInt32LE(value >>> 0, offset);
49
+ }
50
+ // Write int64 little-endian (as two uint32s, safe for values < 2^53)
51
+ function writeI64LE(buf, value, offset) {
52
+ buf.writeUInt32LE(value & 0xffffffff, offset);
53
+ buf.writeUInt32LE(Math.floor(value / 0x100000000) & 0xffffffff, offset + 4);
54
+ }
55
+ // Compact size encoding (Bitcoin varint)
56
+ function compactSize(n) {
57
+ if (n < 0xfd) {
58
+ return Buffer.from([n]);
59
+ }
60
+ else if (n <= 0xffff) {
61
+ const buf = Buffer.alloc(3);
62
+ buf[0] = 0xfd;
63
+ buf.writeUInt16LE(n, 1);
64
+ return buf;
65
+ }
66
+ else {
67
+ const buf = Buffer.alloc(5);
68
+ buf[0] = 0xfe;
69
+ buf.writeUInt32LE(n, 1);
70
+ return buf;
71
+ }
72
+ }
73
+ // Decode a Zcash t-address to its 20-byte pubkey hash
74
+ function decodeTAddress(addr) {
75
+ // Base58 decode
76
+ let num = BigInt(0);
77
+ for (const c of addr) {
78
+ const idx = BASE58_ALPHABET.indexOf(c);
79
+ if (idx < 0)
80
+ throw new Error(`Invalid base58 character: ${c}`);
81
+ num = num * 58n + BigInt(idx);
82
+ }
83
+ // Convert to bytes (26 bytes: 2 version + 20 hash + 4 checksum)
84
+ const bytes = new Uint8Array(26);
85
+ for (let i = 25; i >= 0; i--) {
86
+ bytes[i] = Number(num & 0xffn);
87
+ num = num >> 8n;
88
+ }
89
+ // Verify checksum
90
+ const payload = bytes.subarray(0, 22);
91
+ const checksum = sha256(sha256(payload)).subarray(0, 4);
92
+ for (let i = 0; i < 4; i++) {
93
+ if (bytes[22 + i] !== checksum[i]) {
94
+ throw new Error(`Invalid t-address checksum: ${addr}`);
95
+ }
96
+ }
97
+ const versionHex = Buffer.from(bytes.subarray(0, 2)).toString("hex");
98
+ const info = T_ADDR_VERSIONS[versionHex];
99
+ if (!info) {
100
+ throw new Error(`Unknown t-address version: 0x${versionHex}`);
101
+ }
102
+ return {
103
+ pubkeyHash: Buffer.from(bytes.subarray(2, 22)),
104
+ mainnet: info.mainnet,
105
+ };
106
+ }
107
+ // Build a P2PKH scriptPubKey from a 20-byte pubkey hash
108
+ function p2pkhScript(pubkeyHash) {
109
+ // OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG
110
+ const script = Buffer.alloc(25);
111
+ script[0] = OP_DUP;
112
+ script[1] = OP_HASH160;
113
+ script[2] = 0x14; // push 20 bytes
114
+ pubkeyHash.copy(script, 3);
115
+ script[23] = OP_EQUALVERIFY;
116
+ script[24] = OP_CHECKSIG;
117
+ return script;
118
+ }
119
+ // Build P2PKH scriptPubKey from a t-address string
120
+ function scriptFromAddress(addr) {
121
+ const { pubkeyHash } = decodeTAddress(addr);
122
+ return p2pkhScript(pubkeyHash);
123
+ }
124
+ // Reverse a hex-encoded txid (internal byte order is reversed)
125
+ function reverseTxid(txid) {
126
+ const buf = Buffer.from(txid, "hex");
127
+ if (buf.length !== 32)
128
+ throw new Error(`Invalid txid length: ${buf.length}`);
129
+ return Buffer.from(buf.reverse());
130
+ }
131
+ // Consensus branch ID as 4-byte LE buffer
132
+ function branchIdBytes(branchId) {
133
+ const buf = Buffer.alloc(4);
134
+ writeU32LE(buf, branchId, 0);
135
+ return buf;
136
+ }
137
+ // ZIP 244 sighash computation for v5 transparent transactions
138
+ // Personalization string as bytes, padded/truncated to 16 bytes
139
+ function personalization(tag, suffix) {
140
+ const tagBytes = Buffer.from(tag, "ascii");
141
+ if (suffix) {
142
+ const result = Buffer.alloc(16);
143
+ tagBytes.copy(result, 0, 0, Math.min(tagBytes.length, 12));
144
+ suffix.copy(result, 12, 0, 4);
145
+ return result;
146
+ }
147
+ // Pad to 16 bytes with zeros
148
+ const result = Buffer.alloc(16);
149
+ tagBytes.copy(result, 0, 0, Math.min(tagBytes.length, 16));
150
+ return result;
151
+ }
152
+ // Hash of all prevouts (txid + index) for transparent inputs
153
+ function hashPrevouts(inputs, branchId) {
154
+ const parts = [];
155
+ for (const inp of inputs) {
156
+ const outpoint = Buffer.alloc(36);
157
+ inp.prevTxid.copy(outpoint, 0);
158
+ writeU32LE(outpoint, inp.prevIndex, 32);
159
+ parts.push(outpoint);
160
+ }
161
+ const data = Buffer.concat(parts);
162
+ return blake2b256(data, personalization("ZTxIdPrevoutHash", branchIdBytes(branchId)));
163
+ }
164
+ // Hash of all input amounts
165
+ function hashAmounts(inputs, branchId) {
166
+ const data = Buffer.alloc(inputs.length * 8);
167
+ for (let i = 0; i < inputs.length; i++) {
168
+ writeI64LE(data, inputs[i].value, i * 8);
169
+ }
170
+ return blake2b256(data, personalization("ZTxTrAmountsHash", branchIdBytes(branchId)));
171
+ }
172
+ // Hash of all input scriptPubKeys
173
+ function hashScriptPubKeys(inputs, branchId) {
174
+ const parts = [];
175
+ for (const inp of inputs) {
176
+ parts.push(compactSize(inp.script.length));
177
+ parts.push(inp.script);
178
+ }
179
+ const data = Buffer.concat(parts);
180
+ return blake2b256(data, personalization("ZTxTrScriptsHash", branchIdBytes(branchId)));
181
+ }
182
+ // Hash of all sequences
183
+ function hashSequences(inputs, branchId) {
184
+ const data = Buffer.alloc(inputs.length * 4);
185
+ for (let i = 0; i < inputs.length; i++) {
186
+ writeU32LE(data, inputs[i].sequence, i * 4);
187
+ }
188
+ return blake2b256(data, personalization("ZTxIdSequencHash", branchIdBytes(branchId)));
189
+ }
190
+ // Hash of all transparent outputs
191
+ function hashOutputs(outputs, branchId) {
192
+ const parts = [];
193
+ for (const out of outputs) {
194
+ const valueBuf = Buffer.alloc(8);
195
+ writeI64LE(valueBuf, out.value, 0);
196
+ parts.push(valueBuf);
197
+ parts.push(compactSize(out.script.length));
198
+ parts.push(out.script);
199
+ }
200
+ const data = Buffer.concat(parts);
201
+ return blake2b256(data, personalization("ZTxIdOutputsHash", branchIdBytes(branchId)));
202
+ }
203
+ // Transparent inputs digest (ZIP 244 section T.3a)
204
+ function transparentInputsDigest(inputs, branchId) {
205
+ const prevoutsHash = hashPrevouts(inputs, branchId);
206
+ const amountsHash = hashAmounts(inputs, branchId);
207
+ const scriptPubKeysHash = hashScriptPubKeys(inputs, branchId);
208
+ const sequencesHash = hashSequences(inputs, branchId);
209
+ const data = Buffer.concat([prevoutsHash, amountsHash, scriptPubKeysHash, sequencesHash]);
210
+ return blake2b256(data, personalization("ZTxIdTrInHash__", branchIdBytes(branchId)));
211
+ }
212
+ // Transparent outputs digest (ZIP 244 section T.3b)
213
+ function transparentOutputsDigest(outputs, branchId) {
214
+ const outputsHash = hashOutputs(outputs, branchId);
215
+ return blake2b256(outputsHash, personalization("ZTxIdTrOutHash_", branchIdBytes(branchId)));
216
+ }
217
+ // Full transparent digest for txid (ZIP 244 T.3)
218
+ function transparentDigest(inputs, outputs, branchId) {
219
+ if (inputs.length === 0 && outputs.length === 0) {
220
+ return blake2b256(Buffer.alloc(0), personalization("ZTxIdTranspaHash", branchIdBytes(branchId)));
221
+ }
222
+ const inDigest = transparentInputsDigest(inputs, branchId);
223
+ const outDigest = transparentOutputsDigest(outputs, branchId);
224
+ return blake2b256(Buffer.concat([inDigest, outDigest]), personalization("ZTxIdTranspaHash", branchIdBytes(branchId)));
225
+ }
226
+ // Sapling digest (empty bundle)
227
+ function emptyBundleDigest(tag, branchId) {
228
+ return blake2b256(Buffer.alloc(0), personalization(tag, branchIdBytes(branchId)));
229
+ }
230
+ // Header digest (ZIP 244 T.1)
231
+ function headerDigest(version, versionGroupId, branchId, lockTime, expiryHeight) {
232
+ const data = Buffer.alloc(4 + 4 + 4 + 4 + 4);
233
+ writeU32LE(data, version, 0);
234
+ writeU32LE(data, versionGroupId, 4);
235
+ writeU32LE(data, branchId, 8);
236
+ writeU32LE(data, lockTime, 12);
237
+ writeU32LE(data, expiryHeight, 16);
238
+ return blake2b256(data, personalization("ZTxIdHeadersHash", branchIdBytes(branchId)));
239
+ }
240
+ // Transaction digest for txid (ZIP 244 T)
241
+ function txidDigest(inputs, outputs, branchId, lockTime, expiryHeight) {
242
+ const hdrDigest = headerDigest(TX_VERSION, TX_VERSION_GROUP_ID, branchId, lockTime, expiryHeight);
243
+ const txpDigest = transparentDigest(inputs, outputs, branchId);
244
+ const sapDigest = emptyBundleDigest("ZTxIdSaplingHash", branchId);
245
+ const orchDigest = emptyBundleDigest("ZTxIdOrchardHash", branchId);
246
+ return blake2b256(Buffer.concat([hdrDigest, txpDigest, sapDigest, orchDigest]), personalization("ZcashTxHash__", branchIdBytes(branchId)));
247
+ }
248
+ // Per-input sighash for signing (ZIP 244 S.2 - transparent)
249
+ // This is the hash that actually gets signed by ECDSA
250
+ function transparentSighash(inputs, outputs, branchId, lockTime, expiryHeight, inputIndex, hashType) {
251
+ // T.1: header digest
252
+ const hdrDigest = headerDigest(TX_VERSION, TX_VERSION_GROUP_ID, branchId, lockTime, expiryHeight);
253
+ // T.3: transparent digest (full, for the txid computation)
254
+ const txpDigest = transparentDigest(inputs, outputs, branchId);
255
+ // T.4: sapling digest (empty)
256
+ const sapDigest = emptyBundleDigest("ZTxIdSaplingHash", branchId);
257
+ // T.5: orchard digest (empty)
258
+ const orchDigest = emptyBundleDigest("ZTxIdOrchardHash", branchId);
259
+ // S.2: per-input transparent sighash data
260
+ // hash_type (1 byte)
261
+ // prevout (32 + 4 bytes)
262
+ // value (8 bytes)
263
+ // scriptPubKey (compact size + script bytes)
264
+ // sequence (4 bytes)
265
+ const inp = inputs[inputIndex];
266
+ const prevout = Buffer.alloc(36);
267
+ inp.prevTxid.copy(prevout, 0);
268
+ writeU32LE(prevout, inp.prevIndex, 32);
269
+ const valueBuf = Buffer.alloc(8);
270
+ writeI64LE(valueBuf, inp.value, 0);
271
+ const seqBuf = Buffer.alloc(4);
272
+ writeU32LE(seqBuf, inp.sequence, 0);
273
+ const txinSigDigestData = Buffer.concat([
274
+ Buffer.from([hashType]),
275
+ prevout,
276
+ valueBuf,
277
+ compactSize(inp.script.length),
278
+ inp.script,
279
+ seqBuf,
280
+ ]);
281
+ const txinSigDigest = blake2b256(txinSigDigestData, personalization("Zcash___TxInHash", branchIdBytes(branchId)));
282
+ // Final sighash: BLAKE2b of all digests
283
+ return blake2b256(Buffer.concat([hdrDigest, txpDigest, sapDigest, orchDigest, txinSigDigest]), personalization("ZcashTxHash__", branchIdBytes(branchId)));
284
+ }
285
+ // Serialize a v5 transparent-only transaction to raw bytes.
286
+ // If scriptSigs is provided, inputs get signed scriptSigs.
287
+ // Otherwise inputs get empty scriptSigs (unsigned).
288
+ function serializeTx(inputs, outputs, branchId, lockTime, expiryHeight, scriptSigs) {
289
+ const parts = [];
290
+ // Header
291
+ const header = Buffer.alloc(4);
292
+ // v5: version field encodes (version | fOverwintered flag)
293
+ // fOverwintered = 1 << 31
294
+ writeU32LE(header, (TX_VERSION | (1 << 31)) >>> 0, 0);
295
+ parts.push(header);
296
+ // nVersionGroupId
297
+ const vgid = Buffer.alloc(4);
298
+ writeU32LE(vgid, TX_VERSION_GROUP_ID, 0);
299
+ parts.push(vgid);
300
+ // nConsensusBranchId
301
+ parts.push(branchIdBytes(branchId));
302
+ // nLockTime
303
+ const lt = Buffer.alloc(4);
304
+ writeU32LE(lt, lockTime, 0);
305
+ parts.push(lt);
306
+ // nExpiryHeight
307
+ const eh = Buffer.alloc(4);
308
+ writeU32LE(eh, expiryHeight, 0);
309
+ parts.push(eh);
310
+ // Transparent bundle
311
+ // tx_in_count
312
+ parts.push(compactSize(inputs.length));
313
+ // tx_in
314
+ for (let i = 0; i < inputs.length; i++) {
315
+ const inp = inputs[i];
316
+ // prevout
317
+ const outpoint = Buffer.alloc(36);
318
+ inp.prevTxid.copy(outpoint, 0);
319
+ writeU32LE(outpoint, inp.prevIndex, 32);
320
+ parts.push(outpoint);
321
+ // scriptSig
322
+ const sig = scriptSigs ? scriptSigs[i] : Buffer.alloc(0);
323
+ parts.push(compactSize(sig.length));
324
+ if (sig.length > 0)
325
+ parts.push(sig);
326
+ // sequence
327
+ const seq = Buffer.alloc(4);
328
+ writeU32LE(seq, inp.sequence, 0);
329
+ parts.push(seq);
330
+ }
331
+ // tx_out_count
332
+ parts.push(compactSize(outputs.length));
333
+ // tx_out
334
+ for (const out of outputs) {
335
+ const valueBuf = Buffer.alloc(8);
336
+ writeI64LE(valueBuf, out.value, 0);
337
+ parts.push(valueBuf);
338
+ parts.push(compactSize(out.script.length));
339
+ parts.push(out.script);
340
+ }
341
+ // Sapling bundle (empty)
342
+ parts.push(compactSize(0)); // nSpendsSapling
343
+ parts.push(compactSize(0)); // nOutputsSapling
344
+ // Orchard bundle (empty)
345
+ parts.push(Buffer.from([0x00])); // nActionsOrchard = 0
346
+ return Buffer.concat(parts);
347
+ }
348
+ /**
349
+ * Fetch UTXOs for a transparent address from Zebra RPC.
350
+ * Uses getaddressutxos (requires Zebra with -indexer flag).
351
+ */
352
+ export async function fetchUTXOs(zebraRpcUrl, tAddress) {
353
+ const resp = await fetch(zebraRpcUrl, {
354
+ method: "POST",
355
+ headers: { "Content-Type": "application/json" },
356
+ body: JSON.stringify({
357
+ jsonrpc: "2.0",
358
+ id: 1,
359
+ method: "getaddressutxos",
360
+ params: [{ addresses: [tAddress] }],
361
+ }),
362
+ });
363
+ if (!resp.ok) {
364
+ throw new Error(`Zebra RPC error: ${resp.status} ${resp.statusText}`);
365
+ }
366
+ const data = (await resp.json());
367
+ if (data.error) {
368
+ throw new Error(`Zebra RPC: ${data.error.message || JSON.stringify(data.error)}`);
369
+ }
370
+ const utxos = (data.result || []).map((u) => ({
371
+ txid: u.txid,
372
+ outputIndex: u.outputIndex ?? u.vout ?? u.index,
373
+ script: u.script ?? u.scriptPubKey,
374
+ satoshis: u.satoshis ?? u.value ?? u.amount,
375
+ }));
376
+ return utxos;
377
+ }
378
+ /**
379
+ * Select UTXOs to cover the target amount + fee.
380
+ * Simple largest-first selection. Returns selected UTXOs and total value.
381
+ */
382
+ export function selectUTXOs(utxos, targetAmount, fee) {
383
+ const needed = targetAmount + fee;
384
+ // Sort descending by value
385
+ const sorted = [...utxos].sort((a, b) => b.satoshis - a.satoshis);
386
+ const selected = [];
387
+ let total = 0;
388
+ for (const u of sorted) {
389
+ selected.push(u);
390
+ total += u.satoshis;
391
+ if (total >= needed)
392
+ break;
393
+ }
394
+ if (total < needed) {
395
+ throw new Error(`Insufficient funds: have ${total} zatoshis, need ${needed} (${targetAmount} + ${fee} fee)`);
396
+ }
397
+ return { selected, totalInput: total };
398
+ }
399
+ /**
400
+ * Build an unsigned Zcash v5 transparent transaction.
401
+ *
402
+ * Returns the unsigned serialized TX and per-input sighashes
403
+ * that need to be signed via MPC.
404
+ */
405
+ export function buildUnsignedTx(utxos, recipient, amount, fee = 10000, changeAddress, branchId = BRANCH_ID.NU5) {
406
+ if (utxos.length === 0)
407
+ throw new Error("No UTXOs provided");
408
+ if (amount <= 0)
409
+ throw new Error("Amount must be positive");
410
+ // Build inputs
411
+ const inputs = utxos.map((u) => ({
412
+ prevTxid: reverseTxid(u.txid),
413
+ prevIndex: u.outputIndex,
414
+ script: Buffer.from(u.script, "hex"),
415
+ value: u.satoshis,
416
+ sequence: 0xffffffff,
417
+ }));
418
+ // Build outputs
419
+ const totalInput = utxos.reduce((s, u) => s + u.satoshis, 0);
420
+ const change = totalInput - amount - fee;
421
+ const outputs = [
422
+ { value: amount, script: scriptFromAddress(recipient) },
423
+ ];
424
+ if (change > 0) {
425
+ // Dust threshold: skip change if below 546 zatoshis
426
+ if (change >= 546) {
427
+ outputs.push({ value: change, script: scriptFromAddress(changeAddress) });
428
+ }
429
+ }
430
+ else if (change < 0) {
431
+ throw new Error(`UTXOs total ${totalInput} < amount ${amount} + fee ${fee}`);
432
+ }
433
+ const lockTime = 0;
434
+ const expiryHeight = 0;
435
+ // Serialize unsigned TX
436
+ const unsignedTx = serializeTx(inputs, outputs, branchId, lockTime, expiryHeight);
437
+ // Compute per-input sighashes
438
+ const sighashes = [];
439
+ for (let i = 0; i < inputs.length; i++) {
440
+ const sh = transparentSighash(inputs, outputs, branchId, lockTime, expiryHeight, i, SIGHASH_ALL);
441
+ sighashes.push(sh);
442
+ }
443
+ // Compute txid (hash of unsigned TX structure per ZIP 244)
444
+ const txid = txidDigest(inputs, outputs, branchId, lockTime, expiryHeight);
445
+ return { unsignedTx, sighashes, txid };
446
+ }
447
+ /**
448
+ * Build a P2PKH scriptSig from a DER signature and compressed pubkey.
449
+ *
450
+ * Format: <sig_length> <DER_sig + SIGHASH_ALL_byte> <pubkey_length> <compressed_pubkey>
451
+ */
452
+ function buildScriptSig(derSig, pubkey) {
453
+ // Append SIGHASH_ALL byte to signature
454
+ const sigWithHashType = Buffer.concat([derSig, Buffer.from([SIGHASH_ALL])]);
455
+ const parts = [
456
+ Buffer.from([sigWithHashType.length]),
457
+ sigWithHashType,
458
+ Buffer.from([pubkey.length]),
459
+ pubkey,
460
+ ];
461
+ return Buffer.concat(parts);
462
+ }
463
+ /**
464
+ * Attach MPC signatures to an unsigned transaction.
465
+ *
466
+ * Takes the original UTXO list (to reconstruct inputs/outputs),
467
+ * DER-encoded signatures from MPC, and the compressed pubkey.
468
+ * Returns hex-encoded signed transaction ready for broadcast.
469
+ */
470
+ export function attachSignatures(utxos, recipient, amount, fee, changeAddress, signatures, pubkey, branchId = BRANCH_ID.NU5) {
471
+ if (signatures.length !== utxos.length) {
472
+ throw new Error(`Expected ${utxos.length} signatures, got ${signatures.length}`);
473
+ }
474
+ if (pubkey.length !== 33) {
475
+ throw new Error(`Expected 33-byte compressed pubkey, got ${pubkey.length}`);
476
+ }
477
+ // Rebuild inputs/outputs (same as buildUnsignedTx)
478
+ const inputs = utxos.map((u) => ({
479
+ prevTxid: reverseTxid(u.txid),
480
+ prevIndex: u.outputIndex,
481
+ script: Buffer.from(u.script, "hex"),
482
+ value: u.satoshis,
483
+ sequence: 0xffffffff,
484
+ }));
485
+ const totalInput = utxos.reduce((s, u) => s + u.satoshis, 0);
486
+ const change = totalInput - amount - fee;
487
+ const outputs = [
488
+ { value: amount, script: scriptFromAddress(recipient) },
489
+ ];
490
+ if (change >= 546) {
491
+ outputs.push({ value: change, script: scriptFromAddress(changeAddress) });
492
+ }
493
+ // Build scriptSigs
494
+ const scriptSigs = signatures.map((sig) => buildScriptSig(sig, pubkey));
495
+ // Serialize signed TX
496
+ const signedTx = serializeTx(inputs, outputs, branchId, 0, 0, scriptSigs);
497
+ return signedTx.toString("hex");
498
+ }
499
+ /**
500
+ * Broadcast a signed transaction via Zebra RPC.
501
+ * Returns the txid on success.
502
+ */
503
+ export async function broadcastTx(zebraRpcUrl, txHex) {
504
+ const resp = await fetch(zebraRpcUrl, {
505
+ method: "POST",
506
+ headers: { "Content-Type": "application/json" },
507
+ body: JSON.stringify({
508
+ jsonrpc: "2.0",
509
+ id: 1,
510
+ method: "sendrawtransaction",
511
+ params: [txHex],
512
+ }),
513
+ });
514
+ if (!resp.ok) {
515
+ throw new Error(`Zebra RPC error: ${resp.status} ${resp.statusText}`);
516
+ }
517
+ const data = (await resp.json());
518
+ if (data.error) {
519
+ throw new Error(`Broadcast failed: ${data.error.message || JSON.stringify(data.error)}`);
520
+ }
521
+ return data.result;
522
+ }
523
+ /**
524
+ * Estimate fee for a transparent P2PKH transaction.
525
+ *
526
+ * ZIP 317 marginal fee: max(grace_actions, logical_actions) * 5000
527
+ * For simple P2PKH: 1 input + 2 outputs = 2 logical actions = 10000 zatoshis
528
+ * Each additional input adds 1 logical action = +5000 zatoshis
529
+ */
530
+ export function estimateFee(numInputs, numOutputs) {
531
+ const logicalActions = Math.max(numInputs, numOutputs);
532
+ const graceActions = 2;
533
+ return Math.max(graceActions, logicalActions) * 5000;
534
+ }
package/package.json CHANGED
@@ -1,20 +1,48 @@
1
1
  {
2
2
  "name": "@frontiercompute/zcash-ika",
3
- "version": "0.1.0",
4
- "description": "Zero-trust Zcash agent custody via Ika dWallet. Born shielded, stay shielded.",
3
+ "version": "0.3.0",
4
+ "description": "Split-key custody for Zcash, Bitcoin, and EVM. 2PC-MPC signing, on-chain spend policy, transparent TX builder, ZAP1 attestation.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "scripts": {
8
8
  "build": "tsc",
9
- "test": "node --experimental-vm-modules dist/test.js"
9
+ "test:dkg": "node dist/test-dkg.js",
10
+ "test:sign": "node dist/test-sign.js"
10
11
  },
11
12
  "dependencies": {
12
13
  "@ika.xyz/sdk": "^0.3.1",
13
- "@mysten/sui": "^2.5.0"
14
+ "@mysten/sui": "^2.5.0",
15
+ "blakejs": "^1.2.1"
14
16
  },
15
17
  "devDependencies": {
16
18
  "@types/node": "^25.5.2",
17
19
  "typescript": "^5.4.0"
18
20
  },
21
+ "files": [
22
+ "dist/index.js",
23
+ "dist/index.d.ts",
24
+ "dist/tx-builder.js",
25
+ "dist/tx-builder.d.ts",
26
+ "dist/hybrid.js",
27
+ "dist/hybrid.d.ts"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/Frontier-Compute/zcash-ika.git"
32
+ },
33
+ "author": "zk_nd3r <zk_nd3r@frontiercompute.io>",
34
+ "keywords": [
35
+ "zcash",
36
+ "ika",
37
+ "dwallet",
38
+ "mpc",
39
+ "custody",
40
+ "bitcoin",
41
+ "split-key",
42
+ "policy",
43
+ "sui-move",
44
+ "transparent-tx",
45
+ "attestation"
46
+ ],
19
47
  "license": "MIT"
20
48
  }
@@ -1,17 +0,0 @@
1
- /**
2
- * Ika DKG test - create dWallets on testnet.
3
- *
4
- * Creates:
5
- * 1. Ed25519 dWallet (Zcash Orchard shielded)
6
- * 2. secp256k1 dWallet (Bitcoin + USDC + USDT + any EVM)
7
- *
8
- * One operator, split-key custody across all chains.
9
- * Swiss bank in your pocket. Jailbroken but legal tender.
10
- *
11
- * Requires: SUI_PRIVATE_KEY env var (base64 Sui keypair)
12
- * Get testnet SUI: https://faucet.sui.io
13
- *
14
- * Usage:
15
- * SUI_PRIVATE_KEY=... node dist/test-dkg.js
16
- */
17
- export {};