@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/README.md +126 -92
- package/dist/hybrid.d.ts +119 -0
- package/dist/hybrid.js +148 -0
- package/dist/index.d.ts +117 -65
- package/dist/index.js +671 -88
- package/dist/tx-builder.d.ts +67 -0
- package/dist/tx-builder.js +534 -0
- package/package.json +32 -4
- package/dist/test-dkg.d.ts +0 -17
- package/dist/test-dkg.js +0 -150
- package/src/index.ts +0 -338
- package/src/test-dkg.ts +0 -199
- package/tsconfig.json +0 -13
package/dist/index.js
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @frontiercompute/zcash-ika
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Split-key custody for Zcash transparent, Bitcoin, and EVM chains.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
*
|
|
129
|
+
* Initialize Ika + Sui clients from config.
|
|
61
130
|
*/
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
80
|
-
*
|
|
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.
|
|
84
|
-
* 2.
|
|
85
|
-
* 3. Submit to Ika
|
|
86
|
-
* 4. Poll
|
|
87
|
-
* 5. Extract
|
|
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
|
|
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
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
103
|
-
*
|
|
104
|
-
* The
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
"
|
|
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
|
|
615
|
+
* Spend from a Zcash transparent wallet.
|
|
113
616
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
|
623
|
+
* 6. Attest to ZAP1 as AGENT_ACTION
|
|
120
624
|
*/
|
|
121
|
-
export async function
|
|
122
|
-
|
|
123
|
-
|
|
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,
|
|
136
|
-
throw new Error("spendBitcoin requires
|
|
137
|
-
"
|
|
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 };
|