@frontiercompute/zcash-ika 0.4.0 → 0.6.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.
- package/dist/btc-tx-builder.d.ts +87 -0
- package/dist/btc-tx-builder.js +373 -0
- package/dist/index.d.ts +68 -1
- package/dist/index.js +295 -10
- package/package.json +4 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bitcoin P2PKH transaction builder.
|
|
3
|
+
*
|
|
4
|
+
* Builds legacy (non-segwit) P2PKH transactions for MPC-signed spends.
|
|
5
|
+
* The signing itself happens via Ika dWallet (secp256k1 ECDSA).
|
|
6
|
+
* This module handles UTXO fetch, TX structure, sighash computation,
|
|
7
|
+
* signature attachment, and broadcast via Blockstream API.
|
|
8
|
+
*/
|
|
9
|
+
export type BtcNetwork = "mainnet" | "testnet";
|
|
10
|
+
export interface BtcUTXO {
|
|
11
|
+
txid: string;
|
|
12
|
+
vout: number;
|
|
13
|
+
scriptPubKey: string;
|
|
14
|
+
value: number;
|
|
15
|
+
}
|
|
16
|
+
export interface BtcTxOutput {
|
|
17
|
+
address: string;
|
|
18
|
+
value: number;
|
|
19
|
+
}
|
|
20
|
+
interface BtcInput {
|
|
21
|
+
prevTxid: Buffer;
|
|
22
|
+
prevIndex: number;
|
|
23
|
+
scriptPubKey: Buffer;
|
|
24
|
+
value: number;
|
|
25
|
+
sequence: number;
|
|
26
|
+
}
|
|
27
|
+
interface BtcOutput {
|
|
28
|
+
value: number;
|
|
29
|
+
script: Buffer;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fetch UTXOs for a Bitcoin P2PKH address from Blockstream API.
|
|
33
|
+
*/
|
|
34
|
+
export declare function fetchBtcUTXOs(address: string, network?: BtcNetwork): Promise<BtcUTXO[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Estimate transaction size in bytes for P2PKH.
|
|
37
|
+
* ~148 bytes per input, ~34 bytes per output, 10 bytes overhead.
|
|
38
|
+
*/
|
|
39
|
+
export declare function estimateBtcFee(numInputs: number, numOutputs: number, feeRate: number): number;
|
|
40
|
+
/**
|
|
41
|
+
* Select UTXOs to cover the target amount + estimated fee.
|
|
42
|
+
* Greedy largest-first selection. Re-estimates fee after selection.
|
|
43
|
+
*/
|
|
44
|
+
export declare function selectBtcUTXOs(utxos: BtcUTXO[], targetAmount: number, feeRate: number): {
|
|
45
|
+
selected: BtcUTXO[];
|
|
46
|
+
fee: number;
|
|
47
|
+
totalInput: number;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Compute the legacy P2PKH sighash for a specific input.
|
|
51
|
+
*
|
|
52
|
+
* For each input being signed:
|
|
53
|
+
* 1. Copy the transaction
|
|
54
|
+
* 2. Set all input scriptSigs to empty
|
|
55
|
+
* 3. Set the current input's scriptSig to the previous output's scriptPubKey
|
|
56
|
+
* 4. Append SIGHASH_ALL (0x01000000) as 4 bytes LE
|
|
57
|
+
* 5. Double-SHA256 the result
|
|
58
|
+
*/
|
|
59
|
+
export declare function computeBtcSighash(inputs: BtcInput[], outputs: BtcOutput[], inputIndex: number, hashType?: number): Buffer;
|
|
60
|
+
/**
|
|
61
|
+
* Build an unsigned Bitcoin P2PKH transaction.
|
|
62
|
+
*
|
|
63
|
+
* Returns the per-input sighashes that need to be signed via MPC,
|
|
64
|
+
* plus the internal tx structure needed for signature attachment.
|
|
65
|
+
*/
|
|
66
|
+
export declare function buildUnsignedBtcTx(utxos: BtcUTXO[], txOutputs: BtcTxOutput[], changeAddress: string, fee: number): {
|
|
67
|
+
sighashes: Buffer[];
|
|
68
|
+
inputs: BtcInput[];
|
|
69
|
+
outputs: BtcOutput[];
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Attach DER signatures to an unsigned transaction.
|
|
73
|
+
* Returns the fully serialized signed transaction as a Buffer.
|
|
74
|
+
*/
|
|
75
|
+
export declare function attachBtcSignatures(inputs: BtcInput[], outputs: BtcOutput[], signatures: Buffer[], pubkey: Buffer): Buffer;
|
|
76
|
+
/**
|
|
77
|
+
* Serialize a Bitcoin transaction (version 1, no witness).
|
|
78
|
+
* If scriptSigs is provided, inputs get signed scriptSigs.
|
|
79
|
+
* Otherwise inputs get empty scriptSigs (unsigned).
|
|
80
|
+
*/
|
|
81
|
+
export declare function serializeBtcTx(inputs: BtcInput[], outputs: BtcOutput[], scriptSigs?: Buffer[]): Buffer;
|
|
82
|
+
/**
|
|
83
|
+
* Broadcast a signed transaction via Blockstream API.
|
|
84
|
+
* Returns the txid on success.
|
|
85
|
+
*/
|
|
86
|
+
export declare function broadcastBtcTx(rawHex: string, network?: BtcNetwork): Promise<string>;
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bitcoin P2PKH transaction builder.
|
|
3
|
+
*
|
|
4
|
+
* Builds legacy (non-segwit) P2PKH transactions for MPC-signed spends.
|
|
5
|
+
* The signing itself happens via Ika dWallet (secp256k1 ECDSA).
|
|
6
|
+
* This module handles UTXO fetch, TX structure, sighash computation,
|
|
7
|
+
* signature attachment, and broadcast via Blockstream API.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
// SIGHASH flags
|
|
11
|
+
const SIGHASH_ALL = 0x01;
|
|
12
|
+
// Script opcodes for P2PKH
|
|
13
|
+
const OP_DUP = 0x76;
|
|
14
|
+
const OP_HASH160 = 0xa9;
|
|
15
|
+
const OP_EQUALVERIFY = 0x88;
|
|
16
|
+
const OP_CHECKSIG = 0xac;
|
|
17
|
+
// Bitcoin address version bytes
|
|
18
|
+
const BTC_ADDR_VERSION = {
|
|
19
|
+
mainnet: 0x00,
|
|
20
|
+
testnet: 0x6f,
|
|
21
|
+
};
|
|
22
|
+
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
23
|
+
function sha256(data) {
|
|
24
|
+
return createHash("sha256").update(data).digest();
|
|
25
|
+
}
|
|
26
|
+
function doubleSha256(data) {
|
|
27
|
+
return sha256(sha256(data));
|
|
28
|
+
}
|
|
29
|
+
function hash160(data) {
|
|
30
|
+
return createHash("ripemd160").update(sha256(data)).digest();
|
|
31
|
+
}
|
|
32
|
+
// Write uint32 little-endian
|
|
33
|
+
function writeU32LE(buf, value, offset) {
|
|
34
|
+
buf.writeUInt32LE(value >>> 0, offset);
|
|
35
|
+
}
|
|
36
|
+
// Write int64 little-endian (as two uint32s, safe for values < 2^53)
|
|
37
|
+
function writeI64LE(buf, value, offset) {
|
|
38
|
+
buf.writeUInt32LE(value & 0xffffffff, offset);
|
|
39
|
+
buf.writeUInt32LE(Math.floor(value / 0x100000000) & 0xffffffff, offset + 4);
|
|
40
|
+
}
|
|
41
|
+
// Compact size encoding (Bitcoin varint)
|
|
42
|
+
function compactSize(n) {
|
|
43
|
+
if (n < 0xfd) {
|
|
44
|
+
return Buffer.from([n]);
|
|
45
|
+
}
|
|
46
|
+
else if (n <= 0xffff) {
|
|
47
|
+
const buf = Buffer.alloc(3);
|
|
48
|
+
buf[0] = 0xfd;
|
|
49
|
+
buf.writeUInt16LE(n, 1);
|
|
50
|
+
return buf;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const buf = Buffer.alloc(5);
|
|
54
|
+
buf[0] = 0xfe;
|
|
55
|
+
buf.writeUInt32LE(n, 1);
|
|
56
|
+
return buf;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Reverse a hex-encoded txid (Bitcoin internal byte order is reversed)
|
|
60
|
+
function reverseTxid(txid) {
|
|
61
|
+
const buf = Buffer.from(txid, "hex");
|
|
62
|
+
if (buf.length !== 32)
|
|
63
|
+
throw new Error(`Invalid txid length: ${buf.length}`);
|
|
64
|
+
return Buffer.from(buf.reverse());
|
|
65
|
+
}
|
|
66
|
+
// Decode a Bitcoin base58check address to its 20-byte pubkey hash
|
|
67
|
+
function decodeBtcAddress(addr) {
|
|
68
|
+
let num = BigInt(0);
|
|
69
|
+
for (const c of addr) {
|
|
70
|
+
const idx = BASE58_ALPHABET.indexOf(c);
|
|
71
|
+
if (idx < 0)
|
|
72
|
+
throw new Error(`Invalid base58 character: ${c}`);
|
|
73
|
+
num = num * 58n + BigInt(idx);
|
|
74
|
+
}
|
|
75
|
+
// 25 bytes: 1 version + 20 hash + 4 checksum
|
|
76
|
+
const bytes = new Uint8Array(25);
|
|
77
|
+
for (let i = 24; i >= 0; i--) {
|
|
78
|
+
bytes[i] = Number(num & 0xffn);
|
|
79
|
+
num = num >> 8n;
|
|
80
|
+
}
|
|
81
|
+
// Verify checksum
|
|
82
|
+
const payload = bytes.subarray(0, 21);
|
|
83
|
+
const checksum = doubleSha256(payload).subarray(0, 4);
|
|
84
|
+
for (let i = 0; i < 4; i++) {
|
|
85
|
+
if (bytes[21 + i] !== checksum[i]) {
|
|
86
|
+
throw new Error(`Invalid address checksum: ${addr}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const version = bytes[0];
|
|
90
|
+
let network;
|
|
91
|
+
if (version === BTC_ADDR_VERSION.mainnet) {
|
|
92
|
+
network = "mainnet";
|
|
93
|
+
}
|
|
94
|
+
else if (version === BTC_ADDR_VERSION.testnet) {
|
|
95
|
+
network = "testnet";
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
throw new Error(`Unknown address version byte: 0x${version.toString(16)}`);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
pubkeyHash: Buffer.from(bytes.subarray(1, 21)),
|
|
102
|
+
network,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Build a P2PKH scriptPubKey from a 20-byte pubkey hash
|
|
106
|
+
function p2pkhScript(pubkeyHash) {
|
|
107
|
+
const script = Buffer.alloc(25);
|
|
108
|
+
script[0] = OP_DUP;
|
|
109
|
+
script[1] = OP_HASH160;
|
|
110
|
+
script[2] = 0x14; // push 20 bytes
|
|
111
|
+
pubkeyHash.copy(script, 3);
|
|
112
|
+
script[23] = OP_EQUALVERIFY;
|
|
113
|
+
script[24] = OP_CHECKSIG;
|
|
114
|
+
return script;
|
|
115
|
+
}
|
|
116
|
+
// Build P2PKH scriptPubKey from a Bitcoin address string
|
|
117
|
+
function scriptFromAddress(addr) {
|
|
118
|
+
const { pubkeyHash } = decodeBtcAddress(addr);
|
|
119
|
+
return p2pkhScript(pubkeyHash);
|
|
120
|
+
}
|
|
121
|
+
// Blockstream API base URL
|
|
122
|
+
function apiBase(network) {
|
|
123
|
+
return network === "mainnet"
|
|
124
|
+
? "https://blockstream.info/api"
|
|
125
|
+
: "https://blockstream.info/testnet/api";
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Fetch UTXOs for a Bitcoin P2PKH address from Blockstream API.
|
|
129
|
+
*/
|
|
130
|
+
export async function fetchBtcUTXOs(address, network = "mainnet") {
|
|
131
|
+
const base = apiBase(network);
|
|
132
|
+
const resp = await fetch(`${base}/address/${address}/utxo`);
|
|
133
|
+
if (!resp.ok) {
|
|
134
|
+
throw new Error(`Blockstream API error: ${resp.status} ${resp.statusText}`);
|
|
135
|
+
}
|
|
136
|
+
const data = (await resp.json());
|
|
137
|
+
// Blockstream returns {txid, vout, status, value} but no scriptPubKey.
|
|
138
|
+
// For P2PKH we can derive scriptPubKey from the address.
|
|
139
|
+
const { pubkeyHash } = decodeBtcAddress(address);
|
|
140
|
+
const script = p2pkhScript(pubkeyHash).toString("hex");
|
|
141
|
+
return data.map((u) => ({
|
|
142
|
+
txid: u.txid,
|
|
143
|
+
vout: u.vout,
|
|
144
|
+
scriptPubKey: script,
|
|
145
|
+
value: u.value,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Estimate transaction size in bytes for P2PKH.
|
|
150
|
+
* ~148 bytes per input, ~34 bytes per output, 10 bytes overhead.
|
|
151
|
+
*/
|
|
152
|
+
export function estimateBtcFee(numInputs, numOutputs, feeRate // sat/vbyte
|
|
153
|
+
) {
|
|
154
|
+
const size = 10 + numInputs * 148 + numOutputs * 34;
|
|
155
|
+
return Math.ceil(size * feeRate);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Select UTXOs to cover the target amount + estimated fee.
|
|
159
|
+
* Greedy largest-first selection. Re-estimates fee after selection.
|
|
160
|
+
*/
|
|
161
|
+
export function selectBtcUTXOs(utxos, targetAmount, feeRate // sat/vbyte
|
|
162
|
+
) {
|
|
163
|
+
const sorted = [...utxos].sort((a, b) => b.value - a.value);
|
|
164
|
+
const selected = [];
|
|
165
|
+
let total = 0;
|
|
166
|
+
// Initial estimate: 1 output + 1 change output
|
|
167
|
+
for (const u of sorted) {
|
|
168
|
+
selected.push(u);
|
|
169
|
+
total += u.value;
|
|
170
|
+
const fee = estimateBtcFee(selected.length, 2, feeRate);
|
|
171
|
+
if (total >= targetAmount + fee)
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
const fee = estimateBtcFee(selected.length, 2, feeRate);
|
|
175
|
+
if (total < targetAmount + fee) {
|
|
176
|
+
throw new Error(`Insufficient funds: have ${total} sats, need ${targetAmount + fee} (${targetAmount} + ${fee} fee)`);
|
|
177
|
+
}
|
|
178
|
+
return { selected, fee, totalInput: total };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Compute the legacy P2PKH sighash for a specific input.
|
|
182
|
+
*
|
|
183
|
+
* For each input being signed:
|
|
184
|
+
* 1. Copy the transaction
|
|
185
|
+
* 2. Set all input scriptSigs to empty
|
|
186
|
+
* 3. Set the current input's scriptSig to the previous output's scriptPubKey
|
|
187
|
+
* 4. Append SIGHASH_ALL (0x01000000) as 4 bytes LE
|
|
188
|
+
* 5. Double-SHA256 the result
|
|
189
|
+
*/
|
|
190
|
+
export function computeBtcSighash(inputs, outputs, inputIndex, hashType = SIGHASH_ALL) {
|
|
191
|
+
const parts = [];
|
|
192
|
+
// version
|
|
193
|
+
const ver = Buffer.alloc(4);
|
|
194
|
+
writeU32LE(ver, 1, 0);
|
|
195
|
+
parts.push(ver);
|
|
196
|
+
// input count
|
|
197
|
+
parts.push(compactSize(inputs.length));
|
|
198
|
+
// inputs
|
|
199
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
200
|
+
const inp = inputs[i];
|
|
201
|
+
// prevout (txid + vout)
|
|
202
|
+
const outpoint = Buffer.alloc(36);
|
|
203
|
+
inp.prevTxid.copy(outpoint, 0);
|
|
204
|
+
writeU32LE(outpoint, inp.prevIndex, 32);
|
|
205
|
+
parts.push(outpoint);
|
|
206
|
+
// scriptSig: empty for all inputs except the one being signed
|
|
207
|
+
if (i === inputIndex) {
|
|
208
|
+
parts.push(compactSize(inp.scriptPubKey.length));
|
|
209
|
+
parts.push(inp.scriptPubKey);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
parts.push(compactSize(0));
|
|
213
|
+
}
|
|
214
|
+
// sequence
|
|
215
|
+
const seq = Buffer.alloc(4);
|
|
216
|
+
writeU32LE(seq, inp.sequence, 0);
|
|
217
|
+
parts.push(seq);
|
|
218
|
+
}
|
|
219
|
+
// output count
|
|
220
|
+
parts.push(compactSize(outputs.length));
|
|
221
|
+
// outputs
|
|
222
|
+
for (const out of outputs) {
|
|
223
|
+
const valueBuf = Buffer.alloc(8);
|
|
224
|
+
writeI64LE(valueBuf, out.value, 0);
|
|
225
|
+
parts.push(valueBuf);
|
|
226
|
+
parts.push(compactSize(out.script.length));
|
|
227
|
+
parts.push(out.script);
|
|
228
|
+
}
|
|
229
|
+
// locktime
|
|
230
|
+
const lt = Buffer.alloc(4);
|
|
231
|
+
writeU32LE(lt, 0, 0);
|
|
232
|
+
parts.push(lt);
|
|
233
|
+
// Append hash type as 4 bytes LE
|
|
234
|
+
const ht = Buffer.alloc(4);
|
|
235
|
+
writeU32LE(ht, hashType, 0);
|
|
236
|
+
parts.push(ht);
|
|
237
|
+
return doubleSha256(Buffer.concat(parts));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Build an unsigned Bitcoin P2PKH transaction.
|
|
241
|
+
*
|
|
242
|
+
* Returns the per-input sighashes that need to be signed via MPC,
|
|
243
|
+
* plus the internal tx structure needed for signature attachment.
|
|
244
|
+
*/
|
|
245
|
+
export function buildUnsignedBtcTx(utxos, txOutputs, changeAddress, fee) {
|
|
246
|
+
if (utxos.length === 0)
|
|
247
|
+
throw new Error("No UTXOs provided");
|
|
248
|
+
// Build inputs
|
|
249
|
+
const inputs = utxos.map((u) => ({
|
|
250
|
+
prevTxid: reverseTxid(u.txid),
|
|
251
|
+
prevIndex: u.vout,
|
|
252
|
+
scriptPubKey: Buffer.from(u.scriptPubKey, "hex"),
|
|
253
|
+
value: u.value,
|
|
254
|
+
sequence: 0xffffffff,
|
|
255
|
+
}));
|
|
256
|
+
// Build outputs
|
|
257
|
+
const totalInput = utxos.reduce((s, u) => s + u.value, 0);
|
|
258
|
+
const totalOutput = txOutputs.reduce((s, o) => s + o.value, 0);
|
|
259
|
+
const change = totalInput - totalOutput - fee;
|
|
260
|
+
const outputs = txOutputs.map((o) => ({
|
|
261
|
+
value: o.value,
|
|
262
|
+
script: scriptFromAddress(o.address),
|
|
263
|
+
}));
|
|
264
|
+
if (change > 0) {
|
|
265
|
+
// Dust threshold: skip change if below 546 satoshis
|
|
266
|
+
if (change >= 546) {
|
|
267
|
+
outputs.push({ value: change, script: scriptFromAddress(changeAddress) });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if (change < 0) {
|
|
271
|
+
throw new Error(`UTXOs total ${totalInput} < outputs ${totalOutput} + fee ${fee}`);
|
|
272
|
+
}
|
|
273
|
+
// Compute per-input sighashes
|
|
274
|
+
const sighashes = [];
|
|
275
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
276
|
+
sighashes.push(computeBtcSighash(inputs, outputs, i, SIGHASH_ALL));
|
|
277
|
+
}
|
|
278
|
+
return { sighashes, inputs, outputs };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Build a P2PKH scriptSig from a DER signature and compressed pubkey.
|
|
282
|
+
*
|
|
283
|
+
* Format: <sig_length> <DER_sig + SIGHASH_ALL_byte> <pubkey_length> <compressed_pubkey>
|
|
284
|
+
*/
|
|
285
|
+
function buildScriptSig(derSig, pubkey) {
|
|
286
|
+
const sigWithHashType = Buffer.concat([derSig, Buffer.from([SIGHASH_ALL])]);
|
|
287
|
+
const parts = [
|
|
288
|
+
Buffer.from([sigWithHashType.length]),
|
|
289
|
+
sigWithHashType,
|
|
290
|
+
Buffer.from([pubkey.length]),
|
|
291
|
+
pubkey,
|
|
292
|
+
];
|
|
293
|
+
return Buffer.concat(parts);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Attach DER signatures to an unsigned transaction.
|
|
297
|
+
* Returns the fully serialized signed transaction as a Buffer.
|
|
298
|
+
*/
|
|
299
|
+
export function attachBtcSignatures(inputs, outputs, signatures, pubkey) {
|
|
300
|
+
if (signatures.length !== inputs.length) {
|
|
301
|
+
throw new Error(`Expected ${inputs.length} signatures, got ${signatures.length}`);
|
|
302
|
+
}
|
|
303
|
+
if (pubkey.length !== 33) {
|
|
304
|
+
throw new Error(`Expected 33-byte compressed pubkey, got ${pubkey.length}`);
|
|
305
|
+
}
|
|
306
|
+
const scriptSigs = signatures.map((sig) => buildScriptSig(sig, pubkey));
|
|
307
|
+
return serializeBtcTx(inputs, outputs, scriptSigs);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Serialize a Bitcoin transaction (version 1, no witness).
|
|
311
|
+
* If scriptSigs is provided, inputs get signed scriptSigs.
|
|
312
|
+
* Otherwise inputs get empty scriptSigs (unsigned).
|
|
313
|
+
*/
|
|
314
|
+
export function serializeBtcTx(inputs, outputs, scriptSigs) {
|
|
315
|
+
const parts = [];
|
|
316
|
+
// version: 4 bytes LE
|
|
317
|
+
const ver = Buffer.alloc(4);
|
|
318
|
+
writeU32LE(ver, 1, 0);
|
|
319
|
+
parts.push(ver);
|
|
320
|
+
// input count
|
|
321
|
+
parts.push(compactSize(inputs.length));
|
|
322
|
+
// inputs
|
|
323
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
324
|
+
const inp = inputs[i];
|
|
325
|
+
// prevout
|
|
326
|
+
const outpoint = Buffer.alloc(36);
|
|
327
|
+
inp.prevTxid.copy(outpoint, 0);
|
|
328
|
+
writeU32LE(outpoint, inp.prevIndex, 32);
|
|
329
|
+
parts.push(outpoint);
|
|
330
|
+
// scriptSig
|
|
331
|
+
const sig = scriptSigs ? scriptSigs[i] : Buffer.alloc(0);
|
|
332
|
+
parts.push(compactSize(sig.length));
|
|
333
|
+
if (sig.length > 0)
|
|
334
|
+
parts.push(sig);
|
|
335
|
+
// sequence
|
|
336
|
+
const seq = Buffer.alloc(4);
|
|
337
|
+
writeU32LE(seq, inp.sequence, 0);
|
|
338
|
+
parts.push(seq);
|
|
339
|
+
}
|
|
340
|
+
// output count
|
|
341
|
+
parts.push(compactSize(outputs.length));
|
|
342
|
+
// outputs
|
|
343
|
+
for (const out of outputs) {
|
|
344
|
+
const valueBuf = Buffer.alloc(8);
|
|
345
|
+
writeI64LE(valueBuf, out.value, 0);
|
|
346
|
+
parts.push(valueBuf);
|
|
347
|
+
parts.push(compactSize(out.script.length));
|
|
348
|
+
parts.push(out.script);
|
|
349
|
+
}
|
|
350
|
+
// locktime: 4 bytes LE
|
|
351
|
+
const lt = Buffer.alloc(4);
|
|
352
|
+
writeU32LE(lt, 0, 0);
|
|
353
|
+
parts.push(lt);
|
|
354
|
+
return Buffer.concat(parts);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Broadcast a signed transaction via Blockstream API.
|
|
358
|
+
* Returns the txid on success.
|
|
359
|
+
*/
|
|
360
|
+
export async function broadcastBtcTx(rawHex, network = "mainnet") {
|
|
361
|
+
const base = apiBase(network);
|
|
362
|
+
const resp = await fetch(`${base}/tx`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: { "Content-Type": "text/plain" },
|
|
365
|
+
body: rawHex,
|
|
366
|
+
});
|
|
367
|
+
if (!resp.ok) {
|
|
368
|
+
const body = await resp.text();
|
|
369
|
+
throw new Error(`Broadcast failed: ${resp.status} ${body}`);
|
|
370
|
+
}
|
|
371
|
+
// Blockstream returns the txid as plain text
|
|
372
|
+
return (await resp.text()).trim();
|
|
373
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
export { Curve, Hash, SignatureAlgorithm, IkaClient, IkaTransaction, UserShareEncryptionKeys, getNetworkConfig, createClassGroupsKeypair, createRandomSessionIdentifier, prepareDKG, prepareDKGAsync, prepareDKGSecondRound, prepareDKGSecondRoundAsync, createDKGUserOutput, publicKeyFromDWalletOutput, parseSignatureFromSignOutput, } from "@ika.xyz/sdk";
|
|
17
17
|
export { fetchUTXOs, selectUTXOs, buildUnsignedTx, attachSignatures, broadcastTx, estimateFee, BRANCH_ID, } from "./tx-builder.js";
|
|
18
18
|
export type { UTXO } from "./tx-builder.js";
|
|
19
|
+
export { fetchBtcUTXOs, selectBtcUTXOs, buildUnsignedBtcTx, attachBtcSignatures, serializeBtcTx, broadcastBtcTx, estimateBtcFee, computeBtcSighash, } from "./btc-tx-builder.js";
|
|
20
|
+
export type { BtcUTXO, BtcTxOutput, BtcNetwork } from "./btc-tx-builder.js";
|
|
19
21
|
export type Chain = "zcash-transparent" | "bitcoin" | "ethereum";
|
|
20
22
|
export interface ZcashIkaConfig {
|
|
21
23
|
/** Ika network: mainnet or testnet */
|
|
@@ -214,6 +216,64 @@ export declare function setPolicy(config: ZcashIkaConfig, walletId: string, poli
|
|
|
214
216
|
export declare function checkPolicy(config: ZcashIkaConfig, policyId: string, amount?: number, recipient?: string): Promise<PolicyState & {
|
|
215
217
|
allowed: boolean;
|
|
216
218
|
}>;
|
|
219
|
+
export interface VaultResult {
|
|
220
|
+
/** CustodyVault shared object ID on Sui */
|
|
221
|
+
vaultId: string;
|
|
222
|
+
/** AdminCap object ID (vault creator holds this) */
|
|
223
|
+
adminCapId: string;
|
|
224
|
+
/** Sui transaction digest */
|
|
225
|
+
txDigest: string;
|
|
226
|
+
}
|
|
227
|
+
export interface AgentResult {
|
|
228
|
+
/** AgentCap object ID (issued to the registered agent) */
|
|
229
|
+
agentCapId: string;
|
|
230
|
+
/** Sui transaction digest */
|
|
231
|
+
txDigest: string;
|
|
232
|
+
}
|
|
233
|
+
export interface VaultState {
|
|
234
|
+
vaultId: string;
|
|
235
|
+
dwalletId: string;
|
|
236
|
+
maxPerTx: number;
|
|
237
|
+
maxDaily: number;
|
|
238
|
+
dailySpent: number;
|
|
239
|
+
totalSpent: number;
|
|
240
|
+
totalTxCount: number;
|
|
241
|
+
agentCount: number;
|
|
242
|
+
frozen: boolean;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Create a CustodyVault for a dWallet.
|
|
246
|
+
* The vault enforces spend policy on-chain and tracks agent access.
|
|
247
|
+
* Returns the vault ID and AdminCap (transferred to caller).
|
|
248
|
+
*/
|
|
249
|
+
export declare function createVault(config: ZcashIkaConfig, dwalletId: string, maxPerTx: number, maxDaily: number): Promise<VaultResult>;
|
|
250
|
+
/**
|
|
251
|
+
* Register an agent on a CustodyVault.
|
|
252
|
+
* Only the admin (holder of AdminCap) can do this.
|
|
253
|
+
* Returns the AgentCap which should be transferred to the agent.
|
|
254
|
+
*/
|
|
255
|
+
export declare function registerAgent(config: ZcashIkaConfig, vaultId: string, adminCapId: string, agentAddress: string, agentName: string): Promise<AgentResult>;
|
|
256
|
+
/**
|
|
257
|
+
* Request a spend through the CustodyVault.
|
|
258
|
+
* The Move contract checks all policy constraints on-chain.
|
|
259
|
+
* Aborts if the spend violates any limit.
|
|
260
|
+
*/
|
|
261
|
+
export declare function requestSpend(config: ZcashIkaConfig, vaultId: string, agentCapId: string, amount: number, recipient: string, chain: string): Promise<{
|
|
262
|
+
approved: boolean;
|
|
263
|
+
txDigest: string;
|
|
264
|
+
}>;
|
|
265
|
+
/**
|
|
266
|
+
* Read the on-chain state of a CustodyVault.
|
|
267
|
+
*/
|
|
268
|
+
export declare function getVaultState(config: ZcashIkaConfig, vaultId: string): Promise<VaultState>;
|
|
269
|
+
/**
|
|
270
|
+
* Freeze a CustodyVault. All spend requests will be rejected until unfrozen.
|
|
271
|
+
*/
|
|
272
|
+
export declare function freezeVault(config: ZcashIkaConfig, vaultId: string, adminCapId: string): Promise<string>;
|
|
273
|
+
/**
|
|
274
|
+
* Unfreeze a CustodyVault. Resumes normal spend processing.
|
|
275
|
+
*/
|
|
276
|
+
export declare function unfreezeVault(config: ZcashIkaConfig, vaultId: string, adminCapId: string): Promise<string>;
|
|
217
277
|
/**
|
|
218
278
|
* Spend from a Zcash transparent wallet.
|
|
219
279
|
*
|
|
@@ -228,7 +288,14 @@ export declare function checkPolicy(config: ZcashIkaConfig, policyId: string, am
|
|
|
228
288
|
export declare function spendTransparent(config: ZcashIkaConfig, walletId: string, encryptionSeed: string, request: SpendRequest): Promise<SpendResult>;
|
|
229
289
|
/**
|
|
230
290
|
* Spend from a Bitcoin wallet.
|
|
231
|
-
*
|
|
291
|
+
*
|
|
292
|
+
* Full pipeline:
|
|
293
|
+
* 1. Fetch UTXOs from Blockstream API
|
|
294
|
+
* 2. Build unsigned TX, compute legacy P2PKH sighashes
|
|
295
|
+
* 3. Sign each sighash via Ika 2PC-MPC
|
|
296
|
+
* 4. Attach signatures, serialize signed TX
|
|
297
|
+
* 5. Broadcast via Blockstream API
|
|
298
|
+
* 6. Attest to ZAP1 as AGENT_ACTION
|
|
232
299
|
*/
|
|
233
300
|
export declare function spendBitcoin(config: ZcashIkaConfig, walletId: string, encryptionSeed: string, request: SpendRequest): Promise<SpendResult>;
|
|
234
301
|
/**
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,8 @@ import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
|
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { fetchUTXOs, selectUTXOs, buildUnsignedTx, attachSignatures, broadcastTx, estimateFee, BRANCH_ID, } from "./tx-builder.js";
|
|
23
23
|
export { fetchUTXOs, selectUTXOs, buildUnsignedTx, attachSignatures, broadcastTx, estimateFee, BRANCH_ID, } from "./tx-builder.js";
|
|
24
|
+
import { fetchBtcUTXOs, selectBtcUTXOs, buildUnsignedBtcTx, attachBtcSignatures, broadcastBtcTx, } from "./btc-tx-builder.js";
|
|
25
|
+
export { fetchBtcUTXOs, selectBtcUTXOs, buildUnsignedBtcTx, attachBtcSignatures, serializeBtcTx, broadcastBtcTx, estimateBtcFee, computeBtcSighash, } from "./btc-tx-builder.js";
|
|
24
26
|
const IKA_COIN_TYPE = "0x1f26bb2f711ff82dcda4d02c77d5123089cb7f8418751474b9fb744ce031526a::ika::IKA";
|
|
25
27
|
/** Parameters for dWallet creation per chain.
|
|
26
28
|
*
|
|
@@ -515,9 +517,9 @@ export async function sign(config, request) {
|
|
|
515
517
|
signTxDigest: signResult.digest,
|
|
516
518
|
};
|
|
517
519
|
}
|
|
518
|
-
//
|
|
519
|
-
// Override via POLICY_PACKAGE_ID env var or
|
|
520
|
-
const DEFAULT_POLICY_PACKAGE_ID = "
|
|
520
|
+
// Sui testnet deployment (zap1_policy package)
|
|
521
|
+
// Override via POLICY_PACKAGE_ID env var for mainnet or redeployments
|
|
522
|
+
const DEFAULT_POLICY_PACKAGE_ID = "0xb0468033d854e95ad89de4b6fec8f6d8e8187778c9d8337a6aa30a5c24775a77";
|
|
521
523
|
function getPolicyPackageId() {
|
|
522
524
|
return process.env.POLICY_PACKAGE_ID || DEFAULT_POLICY_PACKAGE_ID;
|
|
523
525
|
}
|
|
@@ -528,10 +530,6 @@ function getPolicyPackageId() {
|
|
|
528
530
|
*/
|
|
529
531
|
export async function setPolicy(config, walletId, policy) {
|
|
530
532
|
const packageId = getPolicyPackageId();
|
|
531
|
-
if (packageId === "0x0") {
|
|
532
|
-
throw new Error("Policy Move module not deployed. Set POLICY_PACKAGE_ID env var " +
|
|
533
|
-
"after running: sui client publish --path move/");
|
|
534
|
-
}
|
|
535
533
|
const { suiClient, keypair } = await initClients(config);
|
|
536
534
|
const tx = new Transaction();
|
|
537
535
|
// 0x6 is the shared Clock object on Sui
|
|
@@ -653,6 +651,211 @@ export async function checkPolicy(config, policyId, amount, recipient) {
|
|
|
653
651
|
}
|
|
654
652
|
return { ...state, allowed };
|
|
655
653
|
}
|
|
654
|
+
/**
|
|
655
|
+
* Create a CustodyVault for a dWallet.
|
|
656
|
+
* The vault enforces spend policy on-chain and tracks agent access.
|
|
657
|
+
* Returns the vault ID and AdminCap (transferred to caller).
|
|
658
|
+
*/
|
|
659
|
+
export async function createVault(config, dwalletId, maxPerTx, maxDaily) {
|
|
660
|
+
const packageId = getPolicyPackageId();
|
|
661
|
+
const { suiClient, keypair } = await initClients(config);
|
|
662
|
+
const sender = keypair.getPublicKey().toSuiAddress();
|
|
663
|
+
const tx = new Transaction();
|
|
664
|
+
const adminCap = tx.moveCall({
|
|
665
|
+
target: `${packageId}::custody::create_vault`,
|
|
666
|
+
arguments: [
|
|
667
|
+
tx.pure.address(dwalletId),
|
|
668
|
+
tx.pure.u64(maxPerTx),
|
|
669
|
+
tx.pure.u64(maxDaily),
|
|
670
|
+
tx.object("0x6"), // Clock
|
|
671
|
+
],
|
|
672
|
+
});
|
|
673
|
+
tx.transferObjects([adminCap], sender);
|
|
674
|
+
const result = await suiClient.signAndExecuteTransaction({
|
|
675
|
+
transaction: tx,
|
|
676
|
+
signer: keypair,
|
|
677
|
+
options: { showEffects: true, showObjectChanges: true },
|
|
678
|
+
});
|
|
679
|
+
if (result.effects?.status?.status !== "success") {
|
|
680
|
+
throw new Error(`createVault TX failed: ${result.effects?.status?.error}`);
|
|
681
|
+
}
|
|
682
|
+
let vaultId = "";
|
|
683
|
+
let adminCapId = "";
|
|
684
|
+
const changes = result.objectChanges || [];
|
|
685
|
+
for (const change of changes) {
|
|
686
|
+
if (change.type !== "created")
|
|
687
|
+
continue;
|
|
688
|
+
const objType = change.objectType || "";
|
|
689
|
+
if (objType.includes("::custody::CustodyVault")) {
|
|
690
|
+
vaultId = change.objectId;
|
|
691
|
+
}
|
|
692
|
+
else if (objType.includes("::custody::AdminCap")) {
|
|
693
|
+
adminCapId = change.objectId;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (!vaultId || !adminCapId) {
|
|
697
|
+
const created = result.effects?.created || [];
|
|
698
|
+
for (const obj of created) {
|
|
699
|
+
const id = obj.reference?.objectId || obj.objectId;
|
|
700
|
+
if (id && !vaultId)
|
|
701
|
+
vaultId = id;
|
|
702
|
+
else if (id && !adminCapId)
|
|
703
|
+
adminCapId = id;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return { vaultId, adminCapId, txDigest: result.digest };
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Register an agent on a CustodyVault.
|
|
710
|
+
* Only the admin (holder of AdminCap) can do this.
|
|
711
|
+
* Returns the AgentCap which should be transferred to the agent.
|
|
712
|
+
*/
|
|
713
|
+
export async function registerAgent(config, vaultId, adminCapId, agentAddress, agentName) {
|
|
714
|
+
const packageId = getPolicyPackageId();
|
|
715
|
+
const { suiClient, keypair } = await initClients(config);
|
|
716
|
+
const sender = keypair.getPublicKey().toSuiAddress();
|
|
717
|
+
const tx = new Transaction();
|
|
718
|
+
const nameBytes = Array.from(new TextEncoder().encode(agentName));
|
|
719
|
+
const agentCap = tx.moveCall({
|
|
720
|
+
target: `${packageId}::custody::register_agent`,
|
|
721
|
+
arguments: [
|
|
722
|
+
tx.object(vaultId),
|
|
723
|
+
tx.object(adminCapId),
|
|
724
|
+
tx.pure.address(agentAddress),
|
|
725
|
+
tx.pure.vector("u8", nameBytes),
|
|
726
|
+
tx.object("0x6"), // Clock
|
|
727
|
+
],
|
|
728
|
+
});
|
|
729
|
+
// Transfer AgentCap to the agent address
|
|
730
|
+
tx.transferObjects([agentCap], agentAddress);
|
|
731
|
+
const result = await suiClient.signAndExecuteTransaction({
|
|
732
|
+
transaction: tx,
|
|
733
|
+
signer: keypair,
|
|
734
|
+
options: { showEffects: true, showObjectChanges: true },
|
|
735
|
+
});
|
|
736
|
+
if (result.effects?.status?.status !== "success") {
|
|
737
|
+
throw new Error(`registerAgent TX failed: ${result.effects?.status?.error}`);
|
|
738
|
+
}
|
|
739
|
+
let agentCapId = "";
|
|
740
|
+
const changes = result.objectChanges || [];
|
|
741
|
+
for (const change of changes) {
|
|
742
|
+
if (change.type !== "created")
|
|
743
|
+
continue;
|
|
744
|
+
const objType = change.objectType || "";
|
|
745
|
+
if (objType.includes("::custody::AgentCap")) {
|
|
746
|
+
agentCapId = change.objectId;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return { agentCapId, txDigest: result.digest };
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Request a spend through the CustodyVault.
|
|
753
|
+
* The Move contract checks all policy constraints on-chain.
|
|
754
|
+
* Aborts if the spend violates any limit.
|
|
755
|
+
*/
|
|
756
|
+
export async function requestSpend(config, vaultId, agentCapId, amount, recipient, chain) {
|
|
757
|
+
const packageId = getPolicyPackageId();
|
|
758
|
+
const { suiClient, keypair } = await initClients(config);
|
|
759
|
+
const tx = new Transaction();
|
|
760
|
+
const recipientBytes = Array.from(new TextEncoder().encode(recipient));
|
|
761
|
+
const chainBytes = Array.from(new TextEncoder().encode(chain));
|
|
762
|
+
tx.moveCall({
|
|
763
|
+
target: `${packageId}::custody::request_spend_entry`,
|
|
764
|
+
arguments: [
|
|
765
|
+
tx.object(vaultId),
|
|
766
|
+
tx.object(agentCapId),
|
|
767
|
+
tx.pure.u64(amount),
|
|
768
|
+
tx.pure.vector("u8", recipientBytes),
|
|
769
|
+
tx.pure.vector("u8", chainBytes),
|
|
770
|
+
tx.object("0x6"), // Clock
|
|
771
|
+
],
|
|
772
|
+
});
|
|
773
|
+
const result = await suiClient.signAndExecuteTransaction({
|
|
774
|
+
transaction: tx,
|
|
775
|
+
signer: keypair,
|
|
776
|
+
options: { showEffects: true },
|
|
777
|
+
});
|
|
778
|
+
if (result.effects?.status?.status !== "success") {
|
|
779
|
+
return { approved: false, txDigest: result.digest };
|
|
780
|
+
}
|
|
781
|
+
return { approved: true, txDigest: result.digest };
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Read the on-chain state of a CustodyVault.
|
|
785
|
+
*/
|
|
786
|
+
export async function getVaultState(config, vaultId) {
|
|
787
|
+
const { suiClient } = await initClients(config);
|
|
788
|
+
const obj = await suiClient.getObject({
|
|
789
|
+
id: vaultId,
|
|
790
|
+
options: { showContent: true },
|
|
791
|
+
});
|
|
792
|
+
const content = obj.data?.content;
|
|
793
|
+
if (!content || content.dataType !== "moveObject") {
|
|
794
|
+
throw new Error(`Vault object ${vaultId} not found or not a Move object`);
|
|
795
|
+
}
|
|
796
|
+
const fields = content.fields;
|
|
797
|
+
return {
|
|
798
|
+
vaultId,
|
|
799
|
+
dwalletId: fields.dwallet_id,
|
|
800
|
+
maxPerTx: Number(fields.max_per_tx),
|
|
801
|
+
maxDaily: Number(fields.max_daily),
|
|
802
|
+
dailySpent: Number(fields.daily_spent),
|
|
803
|
+
totalSpent: Number(fields.total_spent),
|
|
804
|
+
totalTxCount: Number(fields.total_tx_count),
|
|
805
|
+
agentCount: Number(fields.agent_count),
|
|
806
|
+
frozen: fields.frozen,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Freeze a CustodyVault. All spend requests will be rejected until unfrozen.
|
|
811
|
+
*/
|
|
812
|
+
export async function freezeVault(config, vaultId, adminCapId) {
|
|
813
|
+
const packageId = getPolicyPackageId();
|
|
814
|
+
const { suiClient, keypair } = await initClients(config);
|
|
815
|
+
const tx = new Transaction();
|
|
816
|
+
tx.moveCall({
|
|
817
|
+
target: `${packageId}::custody::freeze_vault`,
|
|
818
|
+
arguments: [
|
|
819
|
+
tx.object(vaultId),
|
|
820
|
+
tx.object(adminCapId),
|
|
821
|
+
tx.object("0x6"), // Clock
|
|
822
|
+
],
|
|
823
|
+
});
|
|
824
|
+
const result = await suiClient.signAndExecuteTransaction({
|
|
825
|
+
transaction: tx,
|
|
826
|
+
signer: keypair,
|
|
827
|
+
options: { showEffects: true },
|
|
828
|
+
});
|
|
829
|
+
if (result.effects?.status?.status !== "success") {
|
|
830
|
+
throw new Error(`freezeVault TX failed: ${result.effects?.status?.error}`);
|
|
831
|
+
}
|
|
832
|
+
return result.digest;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Unfreeze a CustodyVault. Resumes normal spend processing.
|
|
836
|
+
*/
|
|
837
|
+
export async function unfreezeVault(config, vaultId, adminCapId) {
|
|
838
|
+
const packageId = getPolicyPackageId();
|
|
839
|
+
const { suiClient, keypair } = await initClients(config);
|
|
840
|
+
const tx = new Transaction();
|
|
841
|
+
tx.moveCall({
|
|
842
|
+
target: `${packageId}::custody::unfreeze_vault`,
|
|
843
|
+
arguments: [
|
|
844
|
+
tx.object(vaultId),
|
|
845
|
+
tx.object(adminCapId),
|
|
846
|
+
tx.object("0x6"), // Clock
|
|
847
|
+
],
|
|
848
|
+
});
|
|
849
|
+
const result = await suiClient.signAndExecuteTransaction({
|
|
850
|
+
transaction: tx,
|
|
851
|
+
signer: keypair,
|
|
852
|
+
options: { showEffects: true },
|
|
853
|
+
});
|
|
854
|
+
if (result.effects?.status?.status !== "success") {
|
|
855
|
+
throw new Error(`unfreezeVault TX failed: ${result.effects?.status?.error}`);
|
|
856
|
+
}
|
|
857
|
+
return result.digest;
|
|
858
|
+
}
|
|
656
859
|
/**
|
|
657
860
|
* Spend from a Zcash transparent wallet.
|
|
658
861
|
*
|
|
@@ -751,11 +954,93 @@ export async function spendTransparent(config, walletId, encryptionSeed, request
|
|
|
751
954
|
}
|
|
752
955
|
/**
|
|
753
956
|
* Spend from a Bitcoin wallet.
|
|
754
|
-
*
|
|
957
|
+
*
|
|
958
|
+
* Full pipeline:
|
|
959
|
+
* 1. Fetch UTXOs from Blockstream API
|
|
960
|
+
* 2. Build unsigned TX, compute legacy P2PKH sighashes
|
|
961
|
+
* 3. Sign each sighash via Ika 2PC-MPC
|
|
962
|
+
* 4. Attach signatures, serialize signed TX
|
|
963
|
+
* 5. Broadcast via Blockstream API
|
|
964
|
+
* 6. Attest to ZAP1 as AGENT_ACTION
|
|
755
965
|
*/
|
|
756
966
|
export async function spendBitcoin(config, walletId, encryptionSeed, request) {
|
|
757
|
-
|
|
758
|
-
|
|
967
|
+
// Fetch the dWallet to get the public key
|
|
968
|
+
const { ikaClient } = await initClients(config);
|
|
969
|
+
const dWallet = await ikaClient.getDWallet(walletId);
|
|
970
|
+
if (!dWallet?.state?.Active) {
|
|
971
|
+
throw new Error(`dWallet ${walletId} not Active`);
|
|
972
|
+
}
|
|
973
|
+
const rawOutput = dWallet.state.Active.public_output;
|
|
974
|
+
const outputBytes = new Uint8Array(Array.isArray(rawOutput) ? rawOutput : Array.from(rawOutput));
|
|
975
|
+
const pubkey = await publicKeyFromDWalletOutput(Curve.SECP256K1, outputBytes);
|
|
976
|
+
if (!pubkey || pubkey.length !== 33) {
|
|
977
|
+
throw new Error("Could not extract 33-byte compressed pubkey from dWallet");
|
|
978
|
+
}
|
|
979
|
+
const btcNetwork = config.network === "mainnet" ? "mainnet" : "testnet";
|
|
980
|
+
// Derive our BTC address from the pubkey
|
|
981
|
+
const ourAddress = deriveBitcoinAddress(pubkey, btcNetwork);
|
|
982
|
+
// Step 1: Fetch UTXOs
|
|
983
|
+
const allUtxos = await fetchBtcUTXOs(ourAddress, btcNetwork);
|
|
984
|
+
if (allUtxos.length === 0) {
|
|
985
|
+
throw new Error(`No UTXOs found for ${ourAddress}`);
|
|
986
|
+
}
|
|
987
|
+
// Step 2: Select UTXOs and build unsigned TX
|
|
988
|
+
const feeRate = 10; // sat/vbyte, conservative default
|
|
989
|
+
const { selected, fee } = selectBtcUTXOs(allUtxos, request.amount, feeRate);
|
|
990
|
+
const { sighashes, inputs, outputs } = buildUnsignedBtcTx(selected, [{ address: request.to, value: request.amount }], ourAddress, // change back to our address
|
|
991
|
+
fee);
|
|
992
|
+
// Step 3: Sign each sighash via MPC
|
|
993
|
+
const signatures = [];
|
|
994
|
+
for (const sighash of sighashes) {
|
|
995
|
+
const signResult = await sign(config, {
|
|
996
|
+
messageHash: new Uint8Array(sighash),
|
|
997
|
+
walletId,
|
|
998
|
+
chain: "bitcoin",
|
|
999
|
+
encryptionSeed,
|
|
1000
|
+
});
|
|
1001
|
+
signatures.push(Buffer.from(signResult.signature));
|
|
1002
|
+
}
|
|
1003
|
+
// Step 4: Attach signatures and serialize
|
|
1004
|
+
const signedTx = attachBtcSignatures(inputs, outputs, signatures, Buffer.from(pubkey));
|
|
1005
|
+
const txHex = signedTx.toString("hex");
|
|
1006
|
+
// Step 5: Broadcast
|
|
1007
|
+
const broadcastTxid = await broadcastBtcTx(txHex, btcNetwork);
|
|
1008
|
+
// Step 6: Attest to ZAP1
|
|
1009
|
+
let leafHash = "";
|
|
1010
|
+
if (config.zap1ApiUrl && config.zap1ApiKey) {
|
|
1011
|
+
try {
|
|
1012
|
+
const attestResp = await fetch(`${config.zap1ApiUrl}/attest`, {
|
|
1013
|
+
method: "POST",
|
|
1014
|
+
headers: {
|
|
1015
|
+
"Content-Type": "application/json",
|
|
1016
|
+
"Authorization": `Bearer ${config.zap1ApiKey}`,
|
|
1017
|
+
},
|
|
1018
|
+
body: JSON.stringify({
|
|
1019
|
+
event_type: "AGENT_ACTION",
|
|
1020
|
+
agent_id: walletId,
|
|
1021
|
+
action: "bitcoin_spend",
|
|
1022
|
+
chain_txid: broadcastTxid,
|
|
1023
|
+
recipient: request.to,
|
|
1024
|
+
amount: request.amount,
|
|
1025
|
+
fee,
|
|
1026
|
+
memo: request.memo || "",
|
|
1027
|
+
}),
|
|
1028
|
+
});
|
|
1029
|
+
if (attestResp.ok) {
|
|
1030
|
+
const attestData = (await attestResp.json());
|
|
1031
|
+
leafHash = attestData.leaf_hash || "";
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
// Attestation failure is non-fatal - tx already broadcast
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return {
|
|
1039
|
+
txid: broadcastTxid,
|
|
1040
|
+
leafHash,
|
|
1041
|
+
chain: "bitcoin",
|
|
1042
|
+
policyChecked: false,
|
|
1043
|
+
};
|
|
759
1044
|
}
|
|
760
1045
|
/**
|
|
761
1046
|
* Verify the wallet's attestation history via ZAP1.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontiercompute/zcash-ika",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
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",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"dist/tx-builder.js",
|
|
26
26
|
"dist/tx-builder.d.ts",
|
|
27
27
|
"dist/hybrid.js",
|
|
28
|
-
"dist/hybrid.d.ts"
|
|
28
|
+
"dist/hybrid.d.ts",
|
|
29
|
+
"dist/btc-tx-builder.js",
|
|
30
|
+
"dist/btc-tx-builder.d.ts"
|
|
29
31
|
],
|
|
30
32
|
"repository": {
|
|
31
33
|
"type": "git",
|