@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.
package/dist/index.js CHANGED
@@ -1,26 +1,33 @@
1
1
  /**
2
2
  * @frontiercompute/zcash-ika
3
3
  *
4
- * Zero-trust custody for Zcash and Bitcoin. Born shielded, stay shielded.
4
+ * Split-key custody for Zcash transparent, Bitcoin, and EVM chains.
5
5
  *
6
- * Two dWallets, one operator:
7
- * - Ed25519 dWallet -> Zcash Orchard (shielded ZEC)
8
- * - secp256k1 dWallet -> Bitcoin (BTC) + Zcash transparent (t-addr)
9
- *
10
- * Neither key ever exists whole. Both chains signed through Ika 2PC-MPC.
6
+ * One secp256k1 dWallet signs for all three chain families.
7
+ * Neither key half can sign alone. Policy enforced by Sui Move contract.
11
8
  * Every operation attested to Zcash via ZAP1.
12
9
  *
13
10
  * Built on Ika's 2PC-MPC network (Sui).
11
+ *
12
+ * NOTE: Zcash shielded (Orchard) uses RedPallas on the Pallas curve,
13
+ * which Ika does not currently support. Only transparent ZEC (secp256k1)
14
+ * is viable through this package today.
14
15
  */
15
16
  export { Curve, Hash, SignatureAlgorithm, IkaClient, IkaTransaction, UserShareEncryptionKeys, getNetworkConfig, createClassGroupsKeypair, createRandomSessionIdentifier, prepareDKG, prepareDKGAsync, prepareDKGSecondRound, prepareDKGSecondRoundAsync, createDKGUserOutput, publicKeyFromDWalletOutput, parseSignatureFromSignOutput, } from "@ika.xyz/sdk";
16
- /** Parameters for dWallet creation per chain */
17
+ import { Curve, Hash, SignatureAlgorithm, IkaClient, IkaTransaction, UserShareEncryptionKeys, getNetworkConfig, createRandomSessionIdentifier, prepareDKGAsync, publicKeyFromDWalletOutput, } from "@ika.xyz/sdk";
18
+ import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
19
+ import { Transaction } from "@mysten/sui/transactions";
20
+ import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
21
+ import { createHash } from "node:crypto";
22
+ import { fetchUTXOs, selectUTXOs, buildUnsignedTx, attachSignatures, broadcastTx, estimateFee, BRANCH_ID, } from "./tx-builder.js";
23
+ export { fetchUTXOs, selectUTXOs, buildUnsignedTx, attachSignatures, broadcastTx, estimateFee, BRANCH_ID, } from "./tx-builder.js";
24
+ const IKA_COIN_TYPE = "0x1f26bb2f711ff82dcda4d02c77d5123089cb7f8418751474b9fb744ce031526a::ika::IKA";
25
+ /** Parameters for dWallet creation per chain.
26
+ *
27
+ * All chains use secp256k1 - one dWallet signs for all of them.
28
+ * Zcash shielded (Orchard) requires RedPallas on the Pallas curve,
29
+ * which is not available in Ika's current MPC. Transparent ZEC works. */
17
30
  export const CHAIN_PARAMS = {
18
- "zcash-shielded": {
19
- curve: "ED25519",
20
- algorithm: "EdDSA",
21
- hash: "SHA512",
22
- description: "Zcash Orchard shielded pool (Ed25519/EdDSA)",
23
- },
24
31
  "zcash-transparent": {
25
32
  curve: "SECP256K1",
26
33
  algorithm: "ECDSASecp256k1",
@@ -33,114 +40,688 @@ export const CHAIN_PARAMS = {
33
40
  hash: "DoubleSHA256",
34
41
  description: "Bitcoin (secp256k1/ECDSA, DoubleSHA256)",
35
42
  },
43
+ ethereum: {
44
+ curve: "SECP256K1",
45
+ algorithm: "ECDSASecp256k1",
46
+ hash: "KECCAK256",
47
+ description: "Ethereum/EVM (secp256k1/ECDSA, KECCAK256)",
48
+ },
49
+ };
50
+ // Zcash t-address version bytes (2 bytes each)
51
+ const ZCASH_VERSION_BYTES = {
52
+ mainnet: Uint8Array.from([0x1c, 0xb8]), // t1...
53
+ testnet: Uint8Array.from([0x1d, 0x25]), // tm...
36
54
  };
55
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
56
+ function base58Encode(data) {
57
+ // Count leading zeros
58
+ let leadingZeros = 0;
59
+ for (const b of data) {
60
+ if (b !== 0)
61
+ break;
62
+ leadingZeros++;
63
+ }
64
+ // Convert to bigint for division
65
+ let num = BigInt(0);
66
+ for (const b of data) {
67
+ num = num * 256n + BigInt(b);
68
+ }
69
+ const chars = [];
70
+ while (num > 0n) {
71
+ const rem = Number(num % 58n);
72
+ num = num / 58n;
73
+ chars.push(BASE58_ALPHABET[rem]);
74
+ }
75
+ // Prepend '1' for each leading zero byte
76
+ for (let i = 0; i < leadingZeros; i++) {
77
+ chars.push("1");
78
+ }
79
+ return chars.reverse().join("");
80
+ }
81
+ function sha256(data) {
82
+ return createHash("sha256").update(data).digest();
83
+ }
84
+ function hash160(data) {
85
+ return createHash("ripemd160").update(sha256(data)).digest();
86
+ }
37
87
  /**
38
- * Create a dual-custody setup: one shielded ZEC wallet + one BTC wallet.
39
- * Same operator controls both via Ika split-key.
88
+ * Derive a Zcash transparent address from a compressed secp256k1 public key.
89
+ *
90
+ * Same as Bitcoin P2PKH but with Zcash 2-byte version prefix:
91
+ * mainnet 0x1cb8 (t1...), testnet 0x1d25 (tm...)
40
92
  *
41
- * Flow per wallet:
42
- * 1. Generate UserShareEncryptionKeys from operator seed
43
- * 2. Run DKG on Ika (2PC-MPC key generation)
44
- * 3. Extract public key, derive chain-specific address
45
- * 4. Attest wallet creation via ZAP1
93
+ * Steps:
94
+ * 1. SHA256(pubkey) then RIPEMD160 = 20-byte hash
95
+ * 2. Prepend 2-byte version
96
+ * 3. Double-SHA256 checksum (first 4 bytes)
97
+ * 4. Base58 encode (version + hash + checksum)
46
98
  */
47
- export async function createDualCustody(config, operatorSeed) {
48
- // Both wallets share one operator seed.
49
- // Each gets its own dWallet with different curve params.
50
- //
51
- // Phase 1 (current): throw with setup instructions
52
- // Phase 2: actual DKG on Ika testnet
53
- throw new Error("createDualCustody requires Ika network access. " +
54
- "Shielded: Ed25519/EdDSA/SHA512 dWallet -> Orchard address. " +
55
- "Bitcoin: secp256k1/ECDSA/DoubleSHA256 dWallet -> BTC address. " +
56
- "npm install @ika.xyz/sdk @mysten/sui && configure Sui wallet. " +
57
- "See https://docs.ika.xyz for DKG walkthrough.");
99
+ export function deriveZcashAddress(publicKey, network = "mainnet") {
100
+ if (publicKey.length !== 33) {
101
+ throw new Error(`Expected 33-byte compressed secp256k1 pubkey, got ${publicKey.length} bytes`);
102
+ }
103
+ const prefix = publicKey[0];
104
+ if (prefix !== 0x02 && prefix !== 0x03) {
105
+ throw new Error(`Invalid compressed pubkey prefix 0x${prefix.toString(16)}, expected 0x02 or 0x03`);
106
+ }
107
+ const pubkeyHash = hash160(publicKey); // 20 bytes
108
+ const version = ZCASH_VERSION_BYTES[network];
109
+ // version (2) + hash160 (20) = 22 bytes
110
+ const payload = new Uint8Array(22);
111
+ payload.set(version, 0);
112
+ payload.set(pubkeyHash, 2);
113
+ // checksum: first 4 bytes of SHA256(SHA256(payload))
114
+ const checksum = sha256(sha256(payload)).subarray(0, 4);
115
+ // final: payload (22) + checksum (4) = 26 bytes
116
+ const full = new Uint8Array(26);
117
+ full.set(payload, 0);
118
+ full.set(checksum, 22);
119
+ return base58Encode(full);
58
120
  }
121
+ // Default poll settings for testnet (epochs can be slow)
122
+ const POLL_OPTS = {
123
+ timeout: 300_000,
124
+ interval: 3_000,
125
+ maxInterval: 10_000,
126
+ backoffMultiplier: 1.5,
127
+ };
59
128
  /**
60
- * Create a single dWallet for a specific chain.
129
+ * Initialize Ika + Sui clients from config.
61
130
  */
62
- export async function createWallet(config, chain, operatorSeed) {
63
- const params = CHAIN_PARAMS[chain];
64
- // The DKG flow:
65
- // 1. IkaClient.init({ network: config.network })
66
- // 2. UserShareEncryptionKeys from operatorSeed
67
- // 3. prepareDKG(curve, signatureAlgorithm, hash)
68
- // 4. Submit DKG round 1 to Ika via IkaTransaction
69
- // 5. prepareDKGSecondRound with network output
70
- // 6. Submit round 2 -> get dWallet object ID + public key
71
- // 7. Derive chain address from public key
72
- throw new Error(`createWallet(${chain}) requires Ika ${config.network} access. ` +
73
- `Params: ${params.curve}/${params.algorithm}/${params.hash}. ` +
74
- `${params.description}.`);
131
+ async function initClients(config) {
132
+ const decoded = decodeSuiPrivateKey(config.suiPrivateKey);
133
+ const keypair = Ed25519Keypair.fromSecretKey(decoded.secretKey);
134
+ const address = keypair.getPublicKey().toSuiAddress();
135
+ const { SuiJsonRpcClient } = await import("@mysten/sui/jsonRpc");
136
+ const rpcUrl = config.suiRpcUrl || (config.network === "testnet"
137
+ ? "https://sui-testnet-rpc.publicnode.com"
138
+ : "https://sui-mainnet-rpc.publicnode.com");
139
+ const suiClient = new SuiJsonRpcClient({
140
+ url: rpcUrl,
141
+ network: config.network,
142
+ });
143
+ const ikaConfig = getNetworkConfig(config.network);
144
+ if (!ikaConfig)
145
+ throw new Error(`No Ika ${config.network} config`);
146
+ const ikaClient = new IkaClient({
147
+ suiClient,
148
+ config: ikaConfig,
149
+ cache: true,
150
+ encryptionKeyOptions: { autoDetect: true },
151
+ });
152
+ await ikaClient.initialize();
153
+ return { ikaClient, suiClient, keypair, address };
75
154
  }
76
155
  /**
77
- * Sign a message hash through Ika 2PC-MPC.
156
+ * Find the IKA coin object ID for an address.
157
+ * IKA is a separate token from SUI - needed for Ika transaction fees.
158
+ */
159
+ async function findIkaCoin(rpcUrl, address) {
160
+ const resp = await fetch(rpcUrl, {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify({
164
+ jsonrpc: "2.0", id: 1,
165
+ method: "suix_getCoins",
166
+ params: [address, IKA_COIN_TYPE, null, 5],
167
+ }),
168
+ });
169
+ const data = (await resp.json());
170
+ const coins = data.result?.data || [];
171
+ if (coins.length === 0) {
172
+ throw new Error("No IKA tokens found. Get them from https://faucet.ika.xyz");
173
+ }
174
+ return coins[0].coinObjectId;
175
+ }
176
+ /**
177
+ * Create a split-key custody wallet.
178
+ * One secp256k1 dWallet signs for Zcash transparent, Bitcoin, and EVM.
78
179
  *
79
- * The operator provides their seed, Ika provides the network share.
80
- * Neither party ever sees the full private key.
180
+ * Returns the dWallet handle with ID, public key, and encryption seed.
181
+ * Save the encryption seed - you need it for signing.
182
+ */
183
+ export async function createDualCustody(config, _operatorSeed) {
184
+ const wallet = await createWallet(config, "zcash-transparent");
185
+ const { address } = await initClients(config);
186
+ return {
187
+ primary: wallet,
188
+ operatorAddress: address,
189
+ };
190
+ }
191
+ /**
192
+ * Create a single secp256k1 dWallet on Ika.
81
193
  *
82
194
  * Flow:
83
- * 1. Create presign session on Ika
84
- * 2. Compute partial user signature locally
85
- * 3. Submit to Ika coordinator
86
- * 4. Poll for completion
87
- * 5. Extract full signature from sign output
195
+ * 1. Generate encryption keys from random seed
196
+ * 2. Prepare DKG locally (WASM crypto)
197
+ * 3. Submit DKG request to Ika network
198
+ * 4. Poll until dWallet reaches Active state
199
+ * 5. Extract compressed public key
88
200
  */
89
- export async function sign(config, operatorSeed, request) {
201
+ export async function createWallet(config, chain, _operatorSeed) {
202
+ const { ikaClient, suiClient, keypair, address } = await initClients(config);
203
+ // Generate encryption keys
204
+ const seed = new Uint8Array(32);
205
+ crypto.getRandomValues(seed);
206
+ const encKeys = await UserShareEncryptionKeys.fromRootSeedKey(seed, Curve.SECP256K1);
207
+ // Prepare DKG
208
+ const bytesToHash = createRandomSessionIdentifier();
209
+ const dkgInput = await prepareDKGAsync(ikaClient, Curve.SECP256K1, encKeys, bytesToHash, address);
210
+ // Build and submit DKG transaction
211
+ const tx = new Transaction();
212
+ const ikaTx = new IkaTransaction({
213
+ ikaClient,
214
+ transaction: tx,
215
+ userShareEncryptionKeys: encKeys,
216
+ });
217
+ const sessionId = ikaTx.registerSessionIdentifier(bytesToHash);
218
+ const networkEncKey = await ikaClient.getLatestNetworkEncryptionKey?.()
219
+ || await ikaClient.getConfiguredNetworkEncryptionKey?.();
220
+ // IKA coin (separate token type) required for Ika fees
221
+ const rpcUrl = config.suiRpcUrl || (config.network === "testnet"
222
+ ? "https://sui-testnet-rpc.publicnode.com"
223
+ : "https://sui-mainnet-rpc.publicnode.com");
224
+ const ikaCoinId = config.ikaCoinId || await findIkaCoin(rpcUrl, address);
225
+ const ikaCoinObj = tx.object(ikaCoinId);
226
+ const dkgReturn = await ikaTx.requestDWalletDKG({
227
+ dkgRequestInput: dkgInput,
228
+ sessionIdentifier: sessionId,
229
+ dwalletNetworkEncryptionKeyId: networkEncKey?.id,
230
+ curve: Curve.SECP256K1,
231
+ ikaCoin: tx.splitCoins(ikaCoinObj, [50_000_000]),
232
+ suiCoin: tx.splitCoins(tx.gas, [50_000_000]),
233
+ });
234
+ if (dkgReturn) {
235
+ tx.transferObjects([dkgReturn], address);
236
+ }
237
+ const result = await suiClient.signAndExecuteTransaction({
238
+ transaction: tx,
239
+ signer: keypair,
240
+ options: { showEffects: true },
241
+ });
242
+ if (result.effects?.status?.status !== "success") {
243
+ throw new Error(`DKG TX failed: ${result.effects?.status?.error}`);
244
+ }
245
+ // Find and poll the dWallet object
246
+ const created = result.effects?.created || [];
247
+ let dwalletId = null;
248
+ let pubkey = null;
249
+ for (const obj of created) {
250
+ const id = obj.reference?.objectId || obj.objectId;
251
+ if (!id)
252
+ continue;
253
+ try {
254
+ const dw = await ikaClient.getDWalletInParticularState(id, "Active", POLL_OPTS);
255
+ if (dw) {
256
+ dwalletId = id;
257
+ try {
258
+ const rawOut = dw.state?.Active?.public_output || dw.publicOutput;
259
+ const outBytes = new Uint8Array(Array.isArray(rawOut) ? rawOut : Array.from(rawOut));
260
+ pubkey = await publicKeyFromDWalletOutput(Curve.SECP256K1, outBytes);
261
+ }
262
+ catch { /* extract later if needed */ }
263
+ break;
264
+ }
265
+ }
266
+ catch {
267
+ // Not a dWallet object or timeout - skip
268
+ }
269
+ }
270
+ if (!dwalletId) {
271
+ throw new Error("DKG completed but could not find Active dWallet in created objects");
272
+ }
273
+ const seedHex = Buffer.from(seed).toString("hex");
274
+ // Derive chain-specific address from compressed pubkey
275
+ let derivedAddress = "";
276
+ if (pubkey && pubkey.length === 33 && chain === "zcash-transparent") {
277
+ derivedAddress = deriveZcashAddress(pubkey, config.network);
278
+ }
279
+ return {
280
+ id: dwalletId,
281
+ publicKey: pubkey || new Uint8Array(0),
282
+ chain,
283
+ address: derivedAddress,
284
+ network: config.network,
285
+ encryptionSeed: seedHex,
286
+ };
287
+ }
288
+ /**
289
+ * Sign a message hash through Ika 2PC-MPC.
290
+ *
291
+ * Two on-chain transactions:
292
+ * 1. Request presign (pre-compute MPC ephemeral key share)
293
+ * 2. Approve message + request signature
294
+ *
295
+ * The operator provides their encryption seed, Ika provides the network share.
296
+ * Neither party ever sees the full private key.
297
+ */
298
+ export async function sign(config, request) {
299
+ const { ikaClient, suiClient, keypair, address } = await initClients(config);
90
300
  const params = CHAIN_PARAMS[request.chain];
91
- // The sign flow:
92
- // 1. IkaClient.init({ network: config.network })
93
- // 2. RequestGlobalPresign for the dWallet
94
- // 3. createUserSignMessageWithCentralizedOutput(messageHash, userShare, ...)
95
- // 4. ApproveMessage on Sui (this is where Move policy gates)
96
- // 5. RequestSign -> poll SessionsManager for Completed status
97
- // 6. parseSignatureFromSignOutput(signOutput)
98
- throw new Error(`sign requires active dWallet + Ika ${config.network}. ` +
99
- `Chain: ${request.chain}, params: ${params.curve}/${params.algorithm}/${params.hash}.`);
301
+ // Reconstruct encryption keys
302
+ const encSeed = Buffer.from(request.encryptionSeed, "hex");
303
+ const encKeys = await UserShareEncryptionKeys.fromRootSeedKey(new Uint8Array(encSeed), Curve.SECP256K1);
304
+ // Fetch dWallet (must be Active)
305
+ const dWallet = await ikaClient.getDWallet(request.walletId);
306
+ if (!dWallet?.state?.Active) {
307
+ throw new Error(`dWallet ${request.walletId} not Active`);
308
+ }
309
+ // Find dWalletCap
310
+ let capId = request.dWalletCapId;
311
+ if (!capId) {
312
+ const capsResult = await ikaClient.getOwnedDWalletCaps(address);
313
+ const cap = (capsResult.dWalletCaps || []).find((c) => c.dwallet_id === request.walletId);
314
+ if (!cap)
315
+ throw new Error(`No dWalletCap found for ${request.walletId}`);
316
+ capId = cap.id;
317
+ }
318
+ // Find IKA coin for fees
319
+ const rpcUrl = config.suiRpcUrl || (config.network === "testnet"
320
+ ? "https://sui-testnet-rpc.publicnode.com"
321
+ : "https://sui-mainnet-rpc.publicnode.com");
322
+ const ikaCoinId = config.ikaCoinId || await findIkaCoin(rpcUrl, address);
323
+ // TX 1: Request presign
324
+ const presignTx = new Transaction();
325
+ const presignIkaTx = new IkaTransaction({
326
+ ikaClient,
327
+ transaction: presignTx,
328
+ userShareEncryptionKeys: encKeys,
329
+ });
330
+ const presignIkaCoin = presignTx.object(ikaCoinId);
331
+ const presignSuiCoin = presignTx.splitCoins(presignTx.gas, [50_000_000]);
332
+ presignIkaTx.requestGlobalPresign({
333
+ dwalletNetworkEncryptionKeyId: dWallet.dwallet_network_encryption_key_id,
334
+ curve: Curve.SECP256K1,
335
+ signatureAlgorithm: SignatureAlgorithm.ECDSASecp256k1,
336
+ ikaCoin: presignIkaCoin,
337
+ suiCoin: presignSuiCoin,
338
+ });
339
+ const presignResult = await suiClient.signAndExecuteTransaction({
340
+ transaction: presignTx,
341
+ signer: keypair,
342
+ options: { showEffects: true },
343
+ });
344
+ if (presignResult.effects?.status?.status !== "success") {
345
+ throw new Error(`Presign TX failed: ${presignResult.effects?.status?.error}`);
346
+ }
347
+ // Find presign session and poll for completion.
348
+ // Poll manually instead of using getPresignInParticularState so we can
349
+ // detect NetworkRejected early rather than burning the full timeout.
350
+ const presignCreated = presignResult.effects?.created || [];
351
+ let completedPresign = null;
352
+ for (const obj of presignCreated) {
353
+ const id = obj.reference?.objectId || obj.objectId;
354
+ if (!id)
355
+ continue;
356
+ try {
357
+ const startTime = Date.now();
358
+ let interval = POLL_OPTS.interval || 3_000;
359
+ while (Date.now() - startTime < (POLL_OPTS.timeout || 300_000)) {
360
+ const presign = await ikaClient.getPresign(id);
361
+ const kind = presign?.state?.$kind;
362
+ if (kind === "Completed") {
363
+ completedPresign = presign;
364
+ break;
365
+ }
366
+ if (kind === "NetworkRejected") {
367
+ throw new Error(`Presign ${id} rejected by network (state: NetworkRejected). ` +
368
+ `This usually means the MPC round was aborted by validators. ` +
369
+ `Retry or check Ika network status.`);
370
+ }
371
+ await new Promise(r => setTimeout(r, interval));
372
+ interval = Math.min(interval * (POLL_OPTS.backoffMultiplier || 1.5), POLL_OPTS.maxInterval || 10_000);
373
+ }
374
+ if (completedPresign)
375
+ break;
376
+ }
377
+ catch (e) {
378
+ if (e.message?.includes("NetworkRejected"))
379
+ throw e;
380
+ // Not a presign object or fetch error, try next created object
381
+ }
382
+ }
383
+ if (!completedPresign) {
384
+ throw new Error("Presign TX succeeded but timed out waiting for completion. Check Ika network status.");
385
+ }
386
+ // TX 2: Approve message + sign
387
+ const hashEnum = Hash[params.hash];
388
+ const signTx = new Transaction();
389
+ const signIkaTx = new IkaTransaction({
390
+ ikaClient,
391
+ transaction: signTx,
392
+ userShareEncryptionKeys: encKeys,
393
+ });
394
+ const verifiedPresignCap = signIkaTx.verifyPresignCap({
395
+ presign: completedPresign,
396
+ });
397
+ const messageApproval = signIkaTx.approveMessage({
398
+ dWalletCap: capId,
399
+ curve: Curve.SECP256K1,
400
+ signatureAlgorithm: SignatureAlgorithm.ECDSASecp256k1,
401
+ hashScheme: hashEnum,
402
+ message: request.messageHash,
403
+ });
404
+ await signIkaTx.requestSign({
405
+ dWallet: dWallet,
406
+ messageApproval,
407
+ hashScheme: hashEnum,
408
+ verifiedPresignCap,
409
+ presign: completedPresign,
410
+ message: request.messageHash,
411
+ signatureScheme: SignatureAlgorithm.ECDSASecp256k1,
412
+ ikaCoin: signTx.splitCoins(signTx.object(ikaCoinId), [50_000_000]),
413
+ suiCoin: signTx.splitCoins(signTx.gas, [50_000_000]),
414
+ });
415
+ const signResult = await suiClient.signAndExecuteTransaction({
416
+ transaction: signTx,
417
+ signer: keypair,
418
+ options: { showEffects: true },
419
+ });
420
+ if (signResult.effects?.status?.status !== "success") {
421
+ throw new Error(`Sign TX failed: ${signResult.effects?.status?.error}`);
422
+ }
423
+ // Find sign session and poll for signature.
424
+ // Same manual polling as presign to detect NetworkRejected early.
425
+ const signCreated = signResult.effects?.created || [];
426
+ let completedSign = null;
427
+ for (const obj of signCreated) {
428
+ const id = obj.reference?.objectId || obj.objectId;
429
+ if (!id)
430
+ continue;
431
+ try {
432
+ const startTime = Date.now();
433
+ let interval = POLL_OPTS.interval || 3_000;
434
+ while (Date.now() - startTime < (POLL_OPTS.timeout || 300_000)) {
435
+ const sign = await ikaClient.getSign(id, Curve.SECP256K1, SignatureAlgorithm.ECDSASecp256k1);
436
+ const kind = sign?.state?.$kind;
437
+ if (kind === "Completed") {
438
+ completedSign = sign;
439
+ break;
440
+ }
441
+ if (kind === "NetworkRejected") {
442
+ throw new Error(`Sign ${id} rejected by network (state: NetworkRejected). ` +
443
+ `MPC signing round aborted. Retry or check Ika network status.`);
444
+ }
445
+ await new Promise(r => setTimeout(r, interval));
446
+ interval = Math.min(interval * (POLL_OPTS.backoffMultiplier || 1.5), POLL_OPTS.maxInterval || 10_000);
447
+ }
448
+ if (completedSign)
449
+ break;
450
+ }
451
+ catch (e) {
452
+ if (e.message?.includes("NetworkRejected"))
453
+ throw e;
454
+ // Not a sign object or fetch error, try next created object
455
+ }
456
+ }
457
+ if (!completedSign?.state?.Completed?.signature) {
458
+ throw new Error("Sign TX succeeded but timed out waiting for signature. Check Ika network status.");
459
+ }
460
+ const rawSig = completedSign.state.Completed.signature;
461
+ const sigBytes = new Uint8Array(Array.isArray(rawSig) ? rawSig : Array.from(rawSig));
462
+ // Extract public key from dWallet
463
+ let pubkey = new Uint8Array(0);
464
+ try {
465
+ const rawOutput = dWallet.state.Active.public_output;
466
+ const outputBytes = new Uint8Array(Array.isArray(rawOutput) ? rawOutput : Array.from(rawOutput));
467
+ pubkey = await publicKeyFromDWalletOutput(Curve.SECP256K1, outputBytes);
468
+ }
469
+ catch { /* non-fatal */ }
470
+ return {
471
+ signature: sigBytes,
472
+ publicKey: pubkey,
473
+ signTxDigest: signResult.digest,
474
+ };
475
+ }
476
+ // Published package ID - set after sui client publish
477
+ // Override via POLICY_PACKAGE_ID env var or pass directly
478
+ const DEFAULT_POLICY_PACKAGE_ID = "0x0";
479
+ function getPolicyPackageId() {
480
+ return process.env.POLICY_PACKAGE_ID || DEFAULT_POLICY_PACKAGE_ID;
100
481
  }
101
482
  /**
102
- * Set spending policy on the dWallet.
103
- * Policy enforced at Sui Move contract level.
104
- * The agent cannot bypass it - the contract holds the DWalletCap.
483
+ * Set spending policy on a dWallet.
484
+ * Creates a SpendPolicy shared object and PolicyCap on Sui.
485
+ * The PolicyCap is transferred to the caller.
105
486
  */
106
487
  export async function setPolicy(config, walletId, policy) {
107
- throw new Error("setPolicy requires a deployed Move module on Sui. " +
108
- "The module gates approve_message() with spending constraints. " +
109
- "See docs/move-policy-template.move for the template.");
488
+ const packageId = getPolicyPackageId();
489
+ if (packageId === "0x0") {
490
+ throw new Error("Policy Move module not deployed. Set POLICY_PACKAGE_ID env var " +
491
+ "after running: sui client publish --path move/");
492
+ }
493
+ const { suiClient, keypair } = await initClients(config);
494
+ const tx = new Transaction();
495
+ // 0x6 is the shared Clock object on Sui
496
+ const cap = tx.moveCall({
497
+ target: `${packageId}::policy::create_policy`,
498
+ arguments: [
499
+ tx.pure.address(walletId),
500
+ tx.pure.u64(policy.maxPerTx),
501
+ tx.pure.u64(policy.maxDaily),
502
+ tx.object("0x6"),
503
+ ],
504
+ });
505
+ // Transfer the returned PolicyCap to sender
506
+ const sender = keypair.getPublicKey().toSuiAddress();
507
+ tx.transferObjects([cap], sender);
508
+ // Add allowed recipients if any
509
+ // Done in separate calls after creation since create_policy starts with empty list
510
+ const result = await suiClient.signAndExecuteTransaction({
511
+ transaction: tx,
512
+ signer: keypair,
513
+ options: { showEffects: true, showObjectChanges: true },
514
+ });
515
+ if (result.effects?.status?.status !== "success") {
516
+ throw new Error(`setPolicy TX failed: ${result.effects?.status?.error}`);
517
+ }
518
+ // Extract created object IDs
519
+ let policyId = "";
520
+ let capId = "";
521
+ const changes = result.objectChanges || [];
522
+ for (const change of changes) {
523
+ if (change.type !== "created")
524
+ continue;
525
+ const objType = change.objectType || "";
526
+ if (objType.includes("::policy::SpendPolicy")) {
527
+ policyId = change.objectId;
528
+ }
529
+ else if (objType.includes("::policy::PolicyCap")) {
530
+ capId = change.objectId;
531
+ }
532
+ }
533
+ if (!policyId || !capId) {
534
+ // Fallback: scan created effects
535
+ const created = result.effects?.created || [];
536
+ for (const obj of created) {
537
+ const id = obj.reference?.objectId || obj.objectId;
538
+ if (id && !policyId)
539
+ policyId = id;
540
+ else if (id && !capId)
541
+ capId = id;
542
+ }
543
+ }
544
+ // Add recipients in a second tx if needed
545
+ if (policy.allowedRecipients.length > 0 && policyId && capId) {
546
+ const tx2 = new Transaction();
547
+ for (const addr of policy.allowedRecipients) {
548
+ const addrBytes = new TextEncoder().encode(addr);
549
+ tx2.moveCall({
550
+ target: `${packageId}::policy::add_recipient_entry`,
551
+ arguments: [
552
+ tx2.object(policyId),
553
+ tx2.object(capId),
554
+ tx2.pure.vector("u8", Array.from(addrBytes)),
555
+ ],
556
+ });
557
+ }
558
+ await suiClient.signAndExecuteTransaction({
559
+ transaction: tx2,
560
+ signer: keypair,
561
+ options: { showEffects: true },
562
+ });
563
+ }
564
+ return { policyId, capId, txDigest: result.digest };
565
+ }
566
+ /**
567
+ * Query a SpendPolicy object and check if a spend would be allowed.
568
+ * Returns the full policy state plus a boolean for the specific check.
569
+ */
570
+ export async function checkPolicy(config, policyId, amount, recipient) {
571
+ const { suiClient } = await initClients(config);
572
+ const obj = await suiClient.getObject({
573
+ id: policyId,
574
+ options: { showContent: true },
575
+ });
576
+ const content = obj.data?.content;
577
+ if (!content || content.dataType !== "moveObject") {
578
+ throw new Error(`Policy object ${policyId} not found or not a Move object`);
579
+ }
580
+ const fields = content.fields;
581
+ const state = {
582
+ policyId,
583
+ dwalletId: fields.dwallet_id,
584
+ owner: fields.owner,
585
+ maxPerTx: Number(fields.max_per_tx),
586
+ maxDaily: Number(fields.max_daily),
587
+ dailySpent: Number(fields.daily_spent),
588
+ windowStart: Number(fields.window_start),
589
+ allowedRecipients: (fields.allowed_recipients || []).map((r) => new TextDecoder().decode(new Uint8Array(r))),
590
+ frozen: fields.frozen,
591
+ };
592
+ // Client-side policy check (mirrors Move logic)
593
+ let allowed = true;
594
+ if (state.frozen) {
595
+ allowed = false;
596
+ }
597
+ else if (amount !== undefined) {
598
+ if (amount > state.maxPerTx) {
599
+ allowed = false;
600
+ }
601
+ else {
602
+ const now = Date.now();
603
+ const daily = (now >= state.windowStart + 86_400_000) ? 0 : state.dailySpent;
604
+ if (daily + amount > state.maxDaily) {
605
+ allowed = false;
606
+ }
607
+ }
608
+ if (allowed && recipient && state.allowedRecipients.length > 0) {
609
+ allowed = state.allowedRecipients.includes(recipient);
610
+ }
611
+ }
612
+ return { ...state, allowed };
110
613
  }
111
614
  /**
112
- * Spend from a shielded ZEC wallet.
615
+ * Spend from a Zcash transparent wallet.
113
616
  *
114
- * 1. Build Zcash Orchard transaction (zcash_primitives)
115
- * 2. Extract sighash
116
- * 3. Sign via Ika 2PC-MPC (Ed25519/EdDSA)
117
- * 4. Attach signature to transaction
617
+ * Full pipeline:
618
+ * 1. Fetch UTXOs from Zebra
619
+ * 2. Build unsigned TX, compute ZIP 244 sighashes
620
+ * 3. Sign each sighash via Ika 2PC-MPC
621
+ * 4. Attach signatures, serialize signed TX
118
622
  * 5. Broadcast via Zebra sendrawtransaction
119
- * 6. Attest via ZAP1 as AGENT_ACTION
623
+ * 6. Attest to ZAP1 as AGENT_ACTION
120
624
  */
121
- export async function spendShielded(config, walletId, operatorSeed, request) {
122
- throw new Error("spendShielded requires active Ed25519 dWallet + Zebra node. " +
123
- "Integration in progress - need Ed25519 -> Orchard spending key derivation bridge.");
625
+ export async function spendTransparent(config, walletId, encryptionSeed, request) {
626
+ const zebraUrl = config.zebraRpcUrl;
627
+ if (!zebraUrl) {
628
+ throw new Error("zebraRpcUrl required for transparent spend");
629
+ }
630
+ // Fetch the dWallet to get the public key
631
+ const { ikaClient } = await initClients(config);
632
+ const dWallet = await ikaClient.getDWallet(walletId);
633
+ if (!dWallet?.state?.Active) {
634
+ throw new Error(`dWallet ${walletId} not Active`);
635
+ }
636
+ const rawOutput = dWallet.state.Active.public_output;
637
+ const outputBytes = new Uint8Array(Array.isArray(rawOutput) ? rawOutput : Array.from(rawOutput));
638
+ const pubkey = await publicKeyFromDWalletOutput(Curve.SECP256K1, outputBytes);
639
+ if (!pubkey || pubkey.length !== 33) {
640
+ throw new Error("Could not extract 33-byte compressed pubkey from dWallet");
641
+ }
642
+ // Derive our t-address from the pubkey
643
+ const ourAddress = deriveZcashAddress(pubkey, config.network);
644
+ // Step 1: Fetch UTXOs
645
+ const allUtxos = await fetchUTXOs(zebraUrl, ourAddress);
646
+ if (allUtxos.length === 0) {
647
+ throw new Error(`No UTXOs found for ${ourAddress}`);
648
+ }
649
+ // Step 2: Select UTXOs and build unsigned TX
650
+ const fee = estimateFee(Math.min(allUtxos.length, 3), // estimate input count
651
+ 2 // recipient + change
652
+ );
653
+ const { selected } = selectUTXOs(allUtxos, request.amount, fee);
654
+ // Recompute fee with actual input count
655
+ const actualFee = estimateFee(selected.length, 2);
656
+ const { unsignedTx, sighashes, txid } = buildUnsignedTx(selected, request.to, request.amount, actualFee, ourAddress, // change back to our address
657
+ BRANCH_ID.NU5);
658
+ // Step 3: Sign each sighash via MPC
659
+ const signatures = [];
660
+ for (const sighash of sighashes) {
661
+ const signResult = await sign(config, {
662
+ messageHash: new Uint8Array(sighash),
663
+ walletId,
664
+ chain: "zcash-transparent",
665
+ encryptionSeed,
666
+ });
667
+ signatures.push(Buffer.from(signResult.signature));
668
+ }
669
+ // Step 4: Attach signatures
670
+ const txHex = attachSignatures(selected, request.to, request.amount, actualFee, ourAddress, signatures, Buffer.from(pubkey), BRANCH_ID.NU5);
671
+ // Step 5: Broadcast
672
+ const broadcastTxid = await broadcastTx(zebraUrl, txHex);
673
+ // Step 6: Attest to ZAP1
674
+ let leafHash = "";
675
+ if (config.zap1ApiUrl && config.zap1ApiKey) {
676
+ try {
677
+ const attestResp = await fetch(`${config.zap1ApiUrl}/attest`, {
678
+ method: "POST",
679
+ headers: {
680
+ "Content-Type": "application/json",
681
+ "Authorization": `Bearer ${config.zap1ApiKey}`,
682
+ },
683
+ body: JSON.stringify({
684
+ event_type: "AGENT_ACTION",
685
+ agent_id: walletId,
686
+ action: "transparent_spend",
687
+ chain_txid: broadcastTxid,
688
+ recipient: request.to,
689
+ amount: request.amount,
690
+ fee: actualFee,
691
+ memo: request.memo || "",
692
+ }),
693
+ });
694
+ if (attestResp.ok) {
695
+ const attestData = (await attestResp.json());
696
+ leafHash = attestData.leaf_hash || "";
697
+ }
698
+ }
699
+ catch {
700
+ // Attestation failure is non-fatal - tx already broadcast
701
+ }
702
+ }
703
+ return {
704
+ txid: broadcastTxid,
705
+ leafHash,
706
+ chain: "zcash-transparent",
707
+ policyChecked: false, // policy enforcement via Move module is separate
708
+ };
124
709
  }
125
710
  /**
126
711
  * Spend from a Bitcoin wallet.
127
- *
128
- * 1. Build Bitcoin transaction
129
- * 2. Compute sighash (DoubleSHA256)
130
- * 3. Sign via Ika 2PC-MPC (secp256k1/ECDSA)
131
- * 4. Attach signature
132
- * 5. Broadcast to Bitcoin network
133
- * 6. Attest via ZAP1 as AGENT_ACTION
712
+ * Same MPC flow as Zcash transparent - DoubleSHA256 sighash, ECDSA signature.
134
713
  */
135
- export async function spendBitcoin(config, walletId, operatorSeed, request) {
136
- throw new Error("spendBitcoin requires active secp256k1 dWallet + Bitcoin node. " +
137
- "Same MPC flow as Zcash transparent - DoubleSHA256 sighash, ECDSA signature.");
714
+ export async function spendBitcoin(config, walletId, encryptionSeed, request) {
715
+ throw new Error("spendBitcoin requires Bitcoin tx builder. " +
716
+ "Use sign() with chain='bitcoin' and a pre-computed sighash for now.");
138
717
  }
139
718
  /**
140
719
  * Verify the wallet's attestation history via ZAP1.
141
720
  * Works today against the live API.
142
721
  */
143
722
  export async function getHistory(config, walletId) {
723
+ if (!config.zap1ApiUrl)
724
+ return [];
144
725
  const resp = await fetch(`${config.zap1ApiUrl}/lifecycle/${walletId}`);
145
726
  if (!resp.ok)
146
727
  return [];
@@ -156,6 +737,8 @@ export async function getHistory(config, walletId) {
156
737
  * Works today against the live API.
157
738
  */
158
739
  export async function checkCompliance(config, walletId) {
740
+ if (!config.zap1ApiUrl)
741
+ return { compliant: false, violations: -1, bondDeposits: 0 };
159
742
  const resp = await fetch(`${config.zap1ApiUrl}/agent/${walletId}/policy/verify`);
160
743
  if (!resp.ok)
161
744
  return { compliant: false, violations: -1, bondDeposits: 0 };