@frontiercompute/zcash-ika 0.2.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 CHANGED
@@ -1,28 +1,31 @@
1
1
  # zcash-ika
2
2
 
3
- Split-key custody for Zcash, Bitcoin, and EVM chains. The private key never exists whole.
3
+ Split-key custody for Zcash, Bitcoin, and EVM chains. The private key never exists whole. Spend policy enforced on-chain. Every action attested to Zcash.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@frontiercompute/zcash-ika)](https://www.npmjs.com/package/@frontiercompute/zcash-ika)
6
6
 
7
- ## What this is
7
+ ## What this does
8
8
 
9
- One secp256k1 dWallet on [Ika's 2PC-MPC network](https://ika.xyz) signs for Zcash transparent, Bitcoin, and Ethereum. Your device holds half the key. Ika's nodes hold the other half. Spending policy enforced by Sui Move contract. Every action attested on Zcash via [ZAP1](https://pay.frontiercompute.io).
9
+ One secp256k1 dWallet on [Ika's 2PC-MPC network](https://ika.xyz) signs for three chain families. Your device holds half the key. Ika's nodes hold the other half. Neither can spend alone.
10
10
 
11
- ## What works today
11
+ - **Spend policy** enforced by Sui Move contract (per-tx limits, daily caps, recipient whitelist, emergency freeze)
12
+ - **Transparent TX builder** for Zcash v5 transactions (ZIP 244 sighash, P2PKH, UTXO selection)
13
+ - **Attestation** of every operation to Zcash mainnet via [ZAP1](https://pay.frontiercompute.io)
14
+ - **5-chain verification** of attestation proofs (Arbitrum, Base, Hyperliquid, Solana, NEAR)
15
+
16
+ ## Chain support
12
17
 
13
18
  | Chain | Curve | Algorithm | Hash | Status |
14
19
  |-------|-------|-----------|------|--------|
15
- | Zcash transparent | secp256k1 | ECDSA | DoubleSHA256 | dWallet on testnet, signing wired |
16
- | Bitcoin | secp256k1 | ECDSA | DoubleSHA256 | Same dWallet, same key |
17
- | Ethereum/EVM | secp256k1 | ECDSA | KECCAK256 | Same dWallet, different hash |
20
+ | Zcash transparent | secp256k1 | ECDSA | DoubleSHA256 | TX builder + signing live |
21
+ | Bitcoin | secp256k1 | ECDSA | DoubleSHA256 | Same dWallet, signing live |
22
+ | Ethereum/EVM | secp256k1 | ECDSA | KECCAK256 | Same dWallet, signing live |
18
23
 
19
24
  One dWallet. Three chain families. Split custody on all of them.
20
25
 
21
26
  ## What does NOT work
22
27
 
23
- **Zcash shielded (Orchard)** requires RedPallas signatures on the Pallas curve. Ika's MPC supports secp256k1 and Ed25519, but not Pallas. There is no path from Ika to Orchard signing today. Same for Sapling (RedJubjub on Jubjub).
24
-
25
- For shielded operations, use the [embedded Orchard wallet](https://github.com/Frontier-Compute/zap1) which holds keys directly. The hybrid architecture: MPC custody for transparent + cross-chain, local wallet for shielded.
28
+ **Zcash shielded (Orchard)** requires RedPallas on the Pallas curve. Ika supports secp256k1 and Ed25519, not Pallas. No path from Ika to Orchard signing today. For shielded, use the [embedded wallet](https://github.com/Frontier-Compute/zap1) which holds Orchard keys directly.
26
29
 
27
30
  ## Install
28
31
 
@@ -30,16 +33,16 @@ For shielded operations, use the [embedded Orchard wallet](https://github.com/Fr
30
33
  npm install @frontiercompute/zcash-ika
31
34
  ```
32
35
 
33
- ## Usage
36
+ ## Quick start
34
37
 
35
38
  ```typescript
36
39
  import {
37
40
  createWallet,
38
41
  sign,
39
- createDualCustody,
40
- getHistory,
41
- checkCompliance,
42
- CHAIN_PARAMS,
42
+ setPolicy,
43
+ spendTransparent,
44
+ checkPolicy,
45
+ deriveZcashAddress,
43
46
  } from "@frontiercompute/zcash-ika";
44
47
 
45
48
  const config = {
@@ -48,81 +51,129 @@ const config = {
48
51
  zap1ApiUrl: "https://pay.frontiercompute.io",
49
52
  };
50
53
 
51
- // Create split-key wallet (secp256k1 - signs for ZEC + BTC + ETH)
52
- const custody = await createDualCustody(config);
53
- console.log("dWallet:", custody.primary.id);
54
- console.log("Save this seed:", custody.primary.encryptionSeed);
55
-
56
- // Sign a Zcash transparent sighash through MPC
57
- const result = await sign(config, {
58
- messageHash: sighashBytes, // DoubleSHA256 of the tx
59
- walletId: custody.primary.id,
60
- chain: "zcash-transparent",
61
- encryptionSeed: custody.primary.encryptionSeed,
62
- });
63
- console.log("Signature:", Buffer.from(result.signature).toString("hex"));
64
-
65
- // Sign Bitcoin (same dWallet, same MPC, same seed)
66
- const btcSig = await sign(config, {
67
- messageHash: btcSighash,
68
- walletId: custody.primary.id,
69
- chain: "bitcoin",
70
- encryptionSeed: custody.primary.encryptionSeed,
54
+ // 1. Create split-key wallet
55
+ const wallet = await createWallet(config);
56
+ console.log("dWallet:", wallet.id);
57
+ console.log("t-addr:", wallet.address);
58
+ console.log("Save this seed:", wallet.encryptionSeed);
59
+
60
+ // 2. Set spend policy (Sui Move contract)
61
+ const policy = await setPolicy(config, wallet.id, {
62
+ maxPerTx: 100_000_000, // 1 ZEC max per transaction
63
+ maxDaily: 500_000_000, // 5 ZEC daily cap
64
+ allowedRecipients: [], // empty = any recipient
65
+ approvalThreshold: 50_000_000, // flag above 0.5 ZEC
71
66
  });
67
+ console.log("Policy:", policy.policyId);
72
68
 
73
- // Compliance check (works now against live ZAP1 API)
74
- const compliance = await checkCompliance(config, custody.primary.id);
69
+ // 3. Check if a spend is allowed
70
+ const check = await checkPolicy(config, policy.policyId, {
71
+ amount: 10_000_000,
72
+ recipient: "t1SomeAddress...",
73
+ });
74
+ console.log("Allowed:", check.allowed);
75
+
76
+ // 4. Spend from the transparent address
77
+ const spend = await spendTransparent(config, {
78
+ walletId: wallet.id,
79
+ encryptionSeed: wallet.encryptionSeed,
80
+ recipient: "t1RecipientAddr...",
81
+ amount: 10_000_000, // 0.1 ZEC
82
+ zebraRpcUrl: "http://localhost:8232",
83
+ });
84
+ console.log("Txid:", spend.txid);
75
85
  ```
76
86
 
77
- ## How it works
87
+ ## Architecture
78
88
 
79
89
  ```
80
- Operator (phone / hardware wallet)
90
+ Operator (device / HSM)
81
91
  |
82
- | user key share (encryption seed)
92
+ | user key share (encryption seed from DKG)
83
93
  |
84
94
  Ika MPC Network (2PC-MPC on Sui)
85
95
  |
86
96
  | network key share (distributed across nodes)
87
97
  |
88
- +-- Spending Policy (Sui Move contract)
89
- | max per tx, daily cap, approved recipients
98
+ +-- Spend Policy (Sui Move contract)
99
+ | max per tx, daily cap, recipient whitelist, freeze
90
100
  |
91
- +-- Sign ZEC transparent tx (secp256k1/ECDSA/DoubleSHA256)
92
- +-- Sign BTC tx (secp256k1/ECDSA/DoubleSHA256)
93
- +-- Sign ETH tx (secp256k1/ECDSA/KECCAK256)
101
+ +-- Sign ZEC transparent (secp256k1 / ECDSA / DoubleSHA256)
102
+ +-- Sign BTC (secp256k1 / ECDSA / DoubleSHA256)
103
+ +-- Sign ETH/EVM (secp256k1 / ECDSA / KECCAK256)
104
+ |
105
+ TX Builder (Zcash v5, ZIP 244)
106
+ +-- UTXO fetch from Zebra
107
+ +-- Sighash computation
108
+ +-- Signature attachment
109
+ +-- Broadcast + attestation
94
110
  |
95
111
  ZAP1 Attestation (Zcash mainnet)
96
- +-- every spend recorded
97
- +-- policy violations on-chain
98
- +-- full audit trail
112
+ +-- every spend recorded in Merkle tree
113
+ +-- anchored to Zcash blockchain
114
+ +-- verified on 5 EVM/L1 chains
99
115
  ```
100
116
 
101
- ## Sign flow (two transactions)
117
+ ## Sign flow
118
+
119
+ 1. **Presign** - pre-compute MPC ephemeral key share (Sui TX 1, poll for completion)
120
+ 2. **Sign** - approve message + request signature (Sui TX 2, poll for completion)
121
+
122
+ Both transactions on Sui. The user partial is computed locally via WASM. Neither party sees the full private key.
123
+
124
+ ## Spend flow (transparent)
102
125
 
103
- 1. **Presign** - pre-compute MPC ephemeral key share (TX 1, poll for completion)
104
- 2. **Sign** - approve message + request signature (TX 2, poll for completion)
126
+ 1. Fetch UTXOs from Zebra RPC (`getaddressutxos`)
127
+ 2. Build unsigned v5 TX with ZIP 244 sighash per input
128
+ 3. Sign each sighash through Ika MPC (presign + sign)
129
+ 4. Attach DER signatures to scriptSig fields
130
+ 5. Broadcast via `sendrawtransaction`
131
+ 6. Attest spend to ZAP1
105
132
 
106
- Both transactions on Sui. The user partial signature is computed locally via WASM. Neither party ever sees the full private key.
133
+ ## Policy enforcement
134
+
135
+ The Sui Move contract (`zap1_policy::policy`) stores:
136
+ - Per-transaction limit (zatoshis)
137
+ - 24-hour rolling daily cap
138
+ - Allowed recipient whitelist (empty = any)
139
+ - Emergency freeze toggle
140
+
141
+ Policy is checked before every MPC sign request. The contract owns the approval gate - you can't bypass it from the client.
142
+
143
+ Deploy: `sui client publish --path move/` then set `POLICY_PACKAGE_ID`.
107
144
 
108
145
  ## On-chain proof
109
146
 
110
- secp256k1 dWallet created on Ika testnet:
147
+ secp256k1 dWallet on Ika testnet:
111
148
 
112
149
  - dWalletId: `0xd9055400c88aeae675413b78143aa54e25eca7061ab659f54a42167cbfdd7aec`
113
150
  - TX: [`CYrS5X1S3itHUtux4qS35AJz5AAyUaJYeWZuqm1CcX2L`](https://testnet.suivision.xyz/txblock/CYrS5X1S3itHUtux4qS35AJz5AAyUaJYeWZuqm1CcX2L)
114
- - Public key: `03ba9e85a85674df494520c2e80b804656fac54fe68668266f33fee9b03ad4b069`
115
- - Derived BTC: `moV3JAzgNa6NkxVfdaNqUjLoDxKEwNAnkX`
151
+ - Compressed pubkey: `03ba9e85a85674df494520c2e80b804656fac54fe68668266f33fee9b03ad4b069`
116
152
  - Derived ZEC t-addr: `t1Rqh1TKqXsSiaV4wrSDandEPccucpHEudn`
117
153
 
154
+ Attestation anchors verified on Arbitrum, Base, Hyperliquid, Solana, NEAR testnet.
155
+
156
+ ## Environment variables
157
+
158
+ ```
159
+ SUI_PRIVATE_KEY - Sui keypair for signing transactions
160
+ POLICY_PACKAGE_ID - Published Move package address (after sui client publish)
161
+ ZAP1_API_URL - ZAP1 attestation API (default: https://pay.frontiercompute.io)
162
+ ZAP1_API_KEY - API key for attestation
163
+ ZEBRA_RPC_URL - Zebra JSON-RPC endpoint for UTXO queries and broadcast
164
+ ```
165
+
118
166
  ## Test scripts
119
167
 
120
168
  ```bash
121
- # Create a new dWallet (saves encryption seed)
169
+ # Create a new dWallet
122
170
  SUI_PRIVATE_KEY=suiprivkey1... node dist/test-dkg.js
123
171
 
124
172
  # Sign a test message through MPC
125
173
  SUI_PRIVATE_KEY=... DWALLET_ID=0x... ENC_SEED=... node dist/test-sign.js
174
+
175
+ # End-to-end: DKG + presign + sign
176
+ SUI_PRIVATE_KEY=... node dist/test-e2e.js
126
177
  ```
127
178
 
128
179
  ## Stack
@@ -130,6 +181,7 @@ SUI_PRIVATE_KEY=... DWALLET_ID=0x... ENC_SEED=... node dist/test-sign.js
130
181
  - [Ika](https://ika.xyz) - 2PC-MPC threshold signing on Sui
131
182
  - [ZAP1](https://pay.frontiercompute.io) - on-chain attestation protocol
132
183
  - [Zebra](https://github.com/ZcashFoundation/zebra) - Zcash node
184
+ - [Sui Move](https://docs.sui.io/concepts/sui-move-concepts) - policy enforcement
133
185
 
134
186
  ## License
135
187
 
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  * is viable through this package today.
15
15
  */
16
16
  export { Curve, Hash, SignatureAlgorithm, IkaClient, IkaTransaction, UserShareEncryptionKeys, getNetworkConfig, createClassGroupsKeypair, createRandomSessionIdentifier, prepareDKG, prepareDKGAsync, prepareDKGSecondRound, prepareDKGSecondRoundAsync, createDKGUserOutput, publicKeyFromDWalletOutput, parseSignatureFromSignOutput, } from "@ika.xyz/sdk";
17
+ export { fetchUTXOs, selectUTXOs, buildUnsignedTx, attachSignatures, broadcastTx, estimateFee, BRANCH_ID, } from "./tx-builder.js";
18
+ export type { UTXO } from "./tx-builder.js";
17
19
  export type Chain = "zcash-transparent" | "bitcoin" | "ethereum";
18
20
  export interface ZcashIkaConfig {
19
21
  /** Ika network: mainnet or testnet */
@@ -167,21 +169,48 @@ export declare function createWallet(config: ZcashIkaConfig, chain: Chain, _oper
167
169
  * Neither party ever sees the full private key.
168
170
  */
169
171
  export declare function sign(config: ZcashIkaConfig, request: SignRequest): Promise<SignResult>;
172
+ export interface PolicyResult {
173
+ /** SpendPolicy shared object ID on Sui */
174
+ policyId: string;
175
+ /** PolicyCap object ID (owner holds this to manage policy) */
176
+ capId: string;
177
+ /** Sui transaction digest */
178
+ txDigest: string;
179
+ }
180
+ export interface PolicyState {
181
+ policyId: string;
182
+ dwalletId: string;
183
+ owner: string;
184
+ maxPerTx: number;
185
+ maxDaily: number;
186
+ dailySpent: number;
187
+ windowStart: number;
188
+ allowedRecipients: string[];
189
+ frozen: boolean;
190
+ }
191
+ /**
192
+ * Set spending policy on a dWallet.
193
+ * Creates a SpendPolicy shared object and PolicyCap on Sui.
194
+ * The PolicyCap is transferred to the caller.
195
+ */
196
+ export declare function setPolicy(config: ZcashIkaConfig, walletId: string, policy: SpendPolicy): Promise<PolicyResult>;
170
197
  /**
171
- * Set spending policy on the dWallet.
172
- * Policy enforced at Sui Move contract level.
173
- * The agent cannot bypass it - the contract holds the DWalletCap.
198
+ * Query a SpendPolicy object and check if a spend would be allowed.
199
+ * Returns the full policy state plus a boolean for the specific check.
174
200
  */
175
- export declare function setPolicy(_config: ZcashIkaConfig, _walletId: string, _policy: SpendPolicy): Promise<string>;
201
+ export declare function checkPolicy(config: ZcashIkaConfig, policyId: string, amount?: number, recipient?: string): Promise<PolicyState & {
202
+ allowed: boolean;
203
+ }>;
176
204
  /**
177
205
  * Spend from a Zcash transparent wallet.
178
206
  *
179
- * 1. Build Zcash transparent transaction (requires Zebra)
180
- * 2. Compute sighash (DoubleSHA256)
181
- * 3. Sign via Ika 2PC-MPC (secp256k1/ECDSA)
182
- * 4. Attach signature to transaction
207
+ * Full pipeline:
208
+ * 1. Fetch UTXOs from Zebra
209
+ * 2. Build unsigned TX, compute ZIP 244 sighashes
210
+ * 3. Sign each sighash via Ika 2PC-MPC
211
+ * 4. Attach signatures, serialize signed TX
183
212
  * 5. Broadcast via Zebra sendrawtransaction
184
- * 6. Attest via ZAP1 as AGENT_ACTION
213
+ * 6. Attest to ZAP1 as AGENT_ACTION
185
214
  */
186
215
  export declare function spendTransparent(config: ZcashIkaConfig, walletId: string, encryptionSeed: string, request: SpendRequest): Promise<SpendResult>;
187
216
  /**
package/dist/index.js CHANGED
@@ -19,6 +19,8 @@ import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
19
19
  import { Transaction } from "@mysten/sui/transactions";
20
20
  import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
21
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";
22
24
  const IKA_COIN_TYPE = "0x1f26bb2f711ff82dcda4d02c77d5123089cb7f8418751474b9fb744ce031526a::ika::IKA";
23
25
  /** Parameters for dWallet creation per chain.
24
26
  *
@@ -471,33 +473,239 @@ export async function sign(config, request) {
471
473
  signTxDigest: signResult.digest,
472
474
  };
473
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;
481
+ }
482
+ /**
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.
486
+ */
487
+ export async function setPolicy(config, walletId, policy) {
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
+ }
474
566
  /**
475
- * Set spending policy on the dWallet.
476
- * Policy enforced at Sui Move contract level.
477
- * The agent cannot bypass it - the contract holds the DWalletCap.
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.
478
569
  */
479
- export async function setPolicy(_config, _walletId, _policy) {
480
- throw new Error("setPolicy requires a deployed Move module on Sui. " +
481
- "The module gates approve_message() with spending constraints. " +
482
- "See docs/move-policy-template.move for the template.");
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 };
483
613
  }
484
614
  /**
485
615
  * Spend from a Zcash transparent wallet.
486
616
  *
487
- * 1. Build Zcash transparent transaction (requires Zebra)
488
- * 2. Compute sighash (DoubleSHA256)
489
- * 3. Sign via Ika 2PC-MPC (secp256k1/ECDSA)
490
- * 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
491
622
  * 5. Broadcast via Zebra sendrawtransaction
492
- * 6. Attest via ZAP1 as AGENT_ACTION
623
+ * 6. Attest to ZAP1 as AGENT_ACTION
493
624
  */
494
625
  export async function spendTransparent(config, walletId, encryptionSeed, request) {
495
- // Build transaction, extract sighash
496
- // For now: the caller provides the sighash directly via sign()
497
- // This function will be the full pipeline once we have tx building
498
- throw new Error("spendTransparent requires Zcash transparent tx builder. " +
499
- "Use sign() directly with a pre-computed sighash for now. " +
500
- "Full pipeline: build tx -> sighash -> sign() -> attach sig -> broadcast.");
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
+ };
501
709
  }
502
710
  /**
503
711
  * Spend from a Bitcoin wallet.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Zcash v5 transparent transaction builder with ZIP 244 sighash.
3
+ *
4
+ * Builds P2PKH transactions for MPC-signed transparent spends.
5
+ * The signing itself happens via Ika dWallet (secp256k1 ECDSA).
6
+ * This module handles everything else: UTXO fetch, TX structure,
7
+ * sighash computation, signature attachment, and broadcast.
8
+ */
9
+ export declare const BRANCH_ID: {
10
+ readonly NU5: 3268858036;
11
+ readonly NU6: 3370586197;
12
+ };
13
+ export interface UTXO {
14
+ txid: string;
15
+ outputIndex: number;
16
+ script: string;
17
+ satoshis: number;
18
+ }
19
+ export interface TxOutput {
20
+ address: string;
21
+ amount: number;
22
+ }
23
+ /**
24
+ * Fetch UTXOs for a transparent address from Zebra RPC.
25
+ * Uses getaddressutxos (requires Zebra with -indexer flag).
26
+ */
27
+ export declare function fetchUTXOs(zebraRpcUrl: string, tAddress: string): Promise<UTXO[]>;
28
+ /**
29
+ * Select UTXOs to cover the target amount + fee.
30
+ * Simple largest-first selection. Returns selected UTXOs and total value.
31
+ */
32
+ export declare function selectUTXOs(utxos: UTXO[], targetAmount: number, fee: number): {
33
+ selected: UTXO[];
34
+ totalInput: number;
35
+ };
36
+ /**
37
+ * Build an unsigned Zcash v5 transparent transaction.
38
+ *
39
+ * Returns the unsigned serialized TX and per-input sighashes
40
+ * that need to be signed via MPC.
41
+ */
42
+ export declare function buildUnsignedTx(utxos: UTXO[], recipient: string, amount: number, fee: number | undefined, changeAddress: string, branchId?: number): {
43
+ unsignedTx: Buffer;
44
+ sighashes: Buffer[];
45
+ txid: Buffer;
46
+ };
47
+ /**
48
+ * Attach MPC signatures to an unsigned transaction.
49
+ *
50
+ * Takes the original UTXO list (to reconstruct inputs/outputs),
51
+ * DER-encoded signatures from MPC, and the compressed pubkey.
52
+ * Returns hex-encoded signed transaction ready for broadcast.
53
+ */
54
+ export declare function attachSignatures(utxos: UTXO[], recipient: string, amount: number, fee: number, changeAddress: string, signatures: Buffer[], pubkey: Buffer, branchId?: number): string;
55
+ /**
56
+ * Broadcast a signed transaction via Zebra RPC.
57
+ * Returns the txid on success.
58
+ */
59
+ export declare function broadcastTx(zebraRpcUrl: string, txHex: string): Promise<string>;
60
+ /**
61
+ * Estimate fee for a transparent P2PKH transaction.
62
+ *
63
+ * ZIP 317 marginal fee: max(grace_actions, logical_actions) * 5000
64
+ * For simple P2PKH: 1 input + 2 outputs = 2 logical actions = 10000 zatoshis
65
+ * Each additional input adds 1 logical action = +5000 zatoshis
66
+ */
67
+ export declare function estimateFee(numInputs: number, numOutputs: number): number;
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Zcash v5 transparent transaction builder with ZIP 244 sighash.
3
+ *
4
+ * Builds P2PKH transactions for MPC-signed transparent spends.
5
+ * The signing itself happens via Ika dWallet (secp256k1 ECDSA).
6
+ * This module handles everything else: UTXO fetch, TX structure,
7
+ * sighash computation, signature attachment, and broadcast.
8
+ */
9
+ import blakejs from "blakejs";
10
+ const { blake2bInit, blake2bUpdate, blake2bFinal } = blakejs;
11
+ import { createHash } from "node:crypto";
12
+ // Zcash v5 transaction constants
13
+ const TX_VERSION = 5;
14
+ const TX_VERSION_GROUP_ID = 0x26a7270a;
15
+ // Consensus branch IDs
16
+ export const BRANCH_ID = {
17
+ NU5: 0xc2d6d0b4,
18
+ NU6: 0xc8e71055,
19
+ };
20
+ // SIGHASH flags
21
+ const SIGHASH_ALL = 0x01;
22
+ // Script opcodes for P2PKH
23
+ const OP_DUP = 0x76;
24
+ const OP_HASH160 = 0xa9;
25
+ const OP_EQUALVERIFY = 0x88;
26
+ const OP_CHECKSIG = 0xac;
27
+ // Zcash t-address version prefixes (for decoding)
28
+ const T_ADDR_VERSIONS = {
29
+ "1cb8": { mainnet: true }, // t1...
30
+ "1d25": { mainnet: false }, // tm...
31
+ };
32
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
33
+ function sha256(data) {
34
+ return createHash("sha256").update(data).digest();
35
+ }
36
+ function hash160(data) {
37
+ return createHash("ripemd160").update(sha256(data)).digest();
38
+ }
39
+ // BLAKE2b-256 with personalization
40
+ // blakejs types don't expose the personal param on blake2bInit, but the JS does
41
+ function blake2b256(data, personal) {
42
+ const ctx = blake2bInit(32, undefined, undefined, personal);
43
+ blake2bUpdate(ctx, data);
44
+ return Buffer.from(blake2bFinal(ctx));
45
+ }
46
+ // Write uint32 little-endian into buffer
47
+ function writeU32LE(buf, value, offset) {
48
+ buf.writeUInt32LE(value >>> 0, offset);
49
+ }
50
+ // Write int64 little-endian (as two uint32s, safe for values < 2^53)
51
+ function writeI64LE(buf, value, offset) {
52
+ buf.writeUInt32LE(value & 0xffffffff, offset);
53
+ buf.writeUInt32LE(Math.floor(value / 0x100000000) & 0xffffffff, offset + 4);
54
+ }
55
+ // Compact size encoding (Bitcoin varint)
56
+ function compactSize(n) {
57
+ if (n < 0xfd) {
58
+ return Buffer.from([n]);
59
+ }
60
+ else if (n <= 0xffff) {
61
+ const buf = Buffer.alloc(3);
62
+ buf[0] = 0xfd;
63
+ buf.writeUInt16LE(n, 1);
64
+ return buf;
65
+ }
66
+ else {
67
+ const buf = Buffer.alloc(5);
68
+ buf[0] = 0xfe;
69
+ buf.writeUInt32LE(n, 1);
70
+ return buf;
71
+ }
72
+ }
73
+ // Decode a Zcash t-address to its 20-byte pubkey hash
74
+ function decodeTAddress(addr) {
75
+ // Base58 decode
76
+ let num = BigInt(0);
77
+ for (const c of addr) {
78
+ const idx = BASE58_ALPHABET.indexOf(c);
79
+ if (idx < 0)
80
+ throw new Error(`Invalid base58 character: ${c}`);
81
+ num = num * 58n + BigInt(idx);
82
+ }
83
+ // Convert to bytes (26 bytes: 2 version + 20 hash + 4 checksum)
84
+ const bytes = new Uint8Array(26);
85
+ for (let i = 25; i >= 0; i--) {
86
+ bytes[i] = Number(num & 0xffn);
87
+ num = num >> 8n;
88
+ }
89
+ // Verify checksum
90
+ const payload = bytes.subarray(0, 22);
91
+ const checksum = sha256(sha256(payload)).subarray(0, 4);
92
+ for (let i = 0; i < 4; i++) {
93
+ if (bytes[22 + i] !== checksum[i]) {
94
+ throw new Error(`Invalid t-address checksum: ${addr}`);
95
+ }
96
+ }
97
+ const versionHex = Buffer.from(bytes.subarray(0, 2)).toString("hex");
98
+ const info = T_ADDR_VERSIONS[versionHex];
99
+ if (!info) {
100
+ throw new Error(`Unknown t-address version: 0x${versionHex}`);
101
+ }
102
+ return {
103
+ pubkeyHash: Buffer.from(bytes.subarray(2, 22)),
104
+ mainnet: info.mainnet,
105
+ };
106
+ }
107
+ // Build a P2PKH scriptPubKey from a 20-byte pubkey hash
108
+ function p2pkhScript(pubkeyHash) {
109
+ // OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG
110
+ const script = Buffer.alloc(25);
111
+ script[0] = OP_DUP;
112
+ script[1] = OP_HASH160;
113
+ script[2] = 0x14; // push 20 bytes
114
+ pubkeyHash.copy(script, 3);
115
+ script[23] = OP_EQUALVERIFY;
116
+ script[24] = OP_CHECKSIG;
117
+ return script;
118
+ }
119
+ // Build P2PKH scriptPubKey from a t-address string
120
+ function scriptFromAddress(addr) {
121
+ const { pubkeyHash } = decodeTAddress(addr);
122
+ return p2pkhScript(pubkeyHash);
123
+ }
124
+ // Reverse a hex-encoded txid (internal byte order is reversed)
125
+ function reverseTxid(txid) {
126
+ const buf = Buffer.from(txid, "hex");
127
+ if (buf.length !== 32)
128
+ throw new Error(`Invalid txid length: ${buf.length}`);
129
+ return Buffer.from(buf.reverse());
130
+ }
131
+ // Consensus branch ID as 4-byte LE buffer
132
+ function branchIdBytes(branchId) {
133
+ const buf = Buffer.alloc(4);
134
+ writeU32LE(buf, branchId, 0);
135
+ return buf;
136
+ }
137
+ // ZIP 244 sighash computation for v5 transparent transactions
138
+ // Personalization string as bytes, padded/truncated to 16 bytes
139
+ function personalization(tag, suffix) {
140
+ const tagBytes = Buffer.from(tag, "ascii");
141
+ if (suffix) {
142
+ const result = Buffer.alloc(16);
143
+ tagBytes.copy(result, 0, 0, Math.min(tagBytes.length, 12));
144
+ suffix.copy(result, 12, 0, 4);
145
+ return result;
146
+ }
147
+ // Pad to 16 bytes with zeros
148
+ const result = Buffer.alloc(16);
149
+ tagBytes.copy(result, 0, 0, Math.min(tagBytes.length, 16));
150
+ return result;
151
+ }
152
+ // Hash of all prevouts (txid + index) for transparent inputs
153
+ function hashPrevouts(inputs, branchId) {
154
+ const parts = [];
155
+ for (const inp of inputs) {
156
+ const outpoint = Buffer.alloc(36);
157
+ inp.prevTxid.copy(outpoint, 0);
158
+ writeU32LE(outpoint, inp.prevIndex, 32);
159
+ parts.push(outpoint);
160
+ }
161
+ const data = Buffer.concat(parts);
162
+ return blake2b256(data, personalization("ZTxIdPrevoutHash", branchIdBytes(branchId)));
163
+ }
164
+ // Hash of all input amounts
165
+ function hashAmounts(inputs, branchId) {
166
+ const data = Buffer.alloc(inputs.length * 8);
167
+ for (let i = 0; i < inputs.length; i++) {
168
+ writeI64LE(data, inputs[i].value, i * 8);
169
+ }
170
+ return blake2b256(data, personalization("ZTxTrAmountsHash", branchIdBytes(branchId)));
171
+ }
172
+ // Hash of all input scriptPubKeys
173
+ function hashScriptPubKeys(inputs, branchId) {
174
+ const parts = [];
175
+ for (const inp of inputs) {
176
+ parts.push(compactSize(inp.script.length));
177
+ parts.push(inp.script);
178
+ }
179
+ const data = Buffer.concat(parts);
180
+ return blake2b256(data, personalization("ZTxTrScriptsHash", branchIdBytes(branchId)));
181
+ }
182
+ // Hash of all sequences
183
+ function hashSequences(inputs, branchId) {
184
+ const data = Buffer.alloc(inputs.length * 4);
185
+ for (let i = 0; i < inputs.length; i++) {
186
+ writeU32LE(data, inputs[i].sequence, i * 4);
187
+ }
188
+ return blake2b256(data, personalization("ZTxIdSequencHash", branchIdBytes(branchId)));
189
+ }
190
+ // Hash of all transparent outputs
191
+ function hashOutputs(outputs, branchId) {
192
+ const parts = [];
193
+ for (const out of outputs) {
194
+ const valueBuf = Buffer.alloc(8);
195
+ writeI64LE(valueBuf, out.value, 0);
196
+ parts.push(valueBuf);
197
+ parts.push(compactSize(out.script.length));
198
+ parts.push(out.script);
199
+ }
200
+ const data = Buffer.concat(parts);
201
+ return blake2b256(data, personalization("ZTxIdOutputsHash", branchIdBytes(branchId)));
202
+ }
203
+ // Transparent inputs digest (ZIP 244 section T.3a)
204
+ function transparentInputsDigest(inputs, branchId) {
205
+ const prevoutsHash = hashPrevouts(inputs, branchId);
206
+ const amountsHash = hashAmounts(inputs, branchId);
207
+ const scriptPubKeysHash = hashScriptPubKeys(inputs, branchId);
208
+ const sequencesHash = hashSequences(inputs, branchId);
209
+ const data = Buffer.concat([prevoutsHash, amountsHash, scriptPubKeysHash, sequencesHash]);
210
+ return blake2b256(data, personalization("ZTxIdTrInHash__", branchIdBytes(branchId)));
211
+ }
212
+ // Transparent outputs digest (ZIP 244 section T.3b)
213
+ function transparentOutputsDigest(outputs, branchId) {
214
+ const outputsHash = hashOutputs(outputs, branchId);
215
+ return blake2b256(outputsHash, personalization("ZTxIdTrOutHash_", branchIdBytes(branchId)));
216
+ }
217
+ // Full transparent digest for txid (ZIP 244 T.3)
218
+ function transparentDigest(inputs, outputs, branchId) {
219
+ if (inputs.length === 0 && outputs.length === 0) {
220
+ return blake2b256(Buffer.alloc(0), personalization("ZTxIdTranspaHash", branchIdBytes(branchId)));
221
+ }
222
+ const inDigest = transparentInputsDigest(inputs, branchId);
223
+ const outDigest = transparentOutputsDigest(outputs, branchId);
224
+ return blake2b256(Buffer.concat([inDigest, outDigest]), personalization("ZTxIdTranspaHash", branchIdBytes(branchId)));
225
+ }
226
+ // Sapling digest (empty bundle)
227
+ function emptyBundleDigest(tag, branchId) {
228
+ return blake2b256(Buffer.alloc(0), personalization(tag, branchIdBytes(branchId)));
229
+ }
230
+ // Header digest (ZIP 244 T.1)
231
+ function headerDigest(version, versionGroupId, branchId, lockTime, expiryHeight) {
232
+ const data = Buffer.alloc(4 + 4 + 4 + 4 + 4);
233
+ writeU32LE(data, version, 0);
234
+ writeU32LE(data, versionGroupId, 4);
235
+ writeU32LE(data, branchId, 8);
236
+ writeU32LE(data, lockTime, 12);
237
+ writeU32LE(data, expiryHeight, 16);
238
+ return blake2b256(data, personalization("ZTxIdHeadersHash", branchIdBytes(branchId)));
239
+ }
240
+ // Transaction digest for txid (ZIP 244 T)
241
+ function txidDigest(inputs, outputs, branchId, lockTime, expiryHeight) {
242
+ const hdrDigest = headerDigest(TX_VERSION, TX_VERSION_GROUP_ID, branchId, lockTime, expiryHeight);
243
+ const txpDigest = transparentDigest(inputs, outputs, branchId);
244
+ const sapDigest = emptyBundleDigest("ZTxIdSaplingHash", branchId);
245
+ const orchDigest = emptyBundleDigest("ZTxIdOrchardHash", branchId);
246
+ return blake2b256(Buffer.concat([hdrDigest, txpDigest, sapDigest, orchDigest]), personalization("ZcashTxHash__", branchIdBytes(branchId)));
247
+ }
248
+ // Per-input sighash for signing (ZIP 244 S.2 - transparent)
249
+ // This is the hash that actually gets signed by ECDSA
250
+ function transparentSighash(inputs, outputs, branchId, lockTime, expiryHeight, inputIndex, hashType) {
251
+ // T.1: header digest
252
+ const hdrDigest = headerDigest(TX_VERSION, TX_VERSION_GROUP_ID, branchId, lockTime, expiryHeight);
253
+ // T.3: transparent digest (full, for the txid computation)
254
+ const txpDigest = transparentDigest(inputs, outputs, branchId);
255
+ // T.4: sapling digest (empty)
256
+ const sapDigest = emptyBundleDigest("ZTxIdSaplingHash", branchId);
257
+ // T.5: orchard digest (empty)
258
+ const orchDigest = emptyBundleDigest("ZTxIdOrchardHash", branchId);
259
+ // S.2: per-input transparent sighash data
260
+ // hash_type (1 byte)
261
+ // prevout (32 + 4 bytes)
262
+ // value (8 bytes)
263
+ // scriptPubKey (compact size + script bytes)
264
+ // sequence (4 bytes)
265
+ const inp = inputs[inputIndex];
266
+ const prevout = Buffer.alloc(36);
267
+ inp.prevTxid.copy(prevout, 0);
268
+ writeU32LE(prevout, inp.prevIndex, 32);
269
+ const valueBuf = Buffer.alloc(8);
270
+ writeI64LE(valueBuf, inp.value, 0);
271
+ const seqBuf = Buffer.alloc(4);
272
+ writeU32LE(seqBuf, inp.sequence, 0);
273
+ const txinSigDigestData = Buffer.concat([
274
+ Buffer.from([hashType]),
275
+ prevout,
276
+ valueBuf,
277
+ compactSize(inp.script.length),
278
+ inp.script,
279
+ seqBuf,
280
+ ]);
281
+ const txinSigDigest = blake2b256(txinSigDigestData, personalization("Zcash___TxInHash", branchIdBytes(branchId)));
282
+ // Final sighash: BLAKE2b of all digests
283
+ return blake2b256(Buffer.concat([hdrDigest, txpDigest, sapDigest, orchDigest, txinSigDigest]), personalization("ZcashTxHash__", branchIdBytes(branchId)));
284
+ }
285
+ // Serialize a v5 transparent-only transaction to raw bytes.
286
+ // If scriptSigs is provided, inputs get signed scriptSigs.
287
+ // Otherwise inputs get empty scriptSigs (unsigned).
288
+ function serializeTx(inputs, outputs, branchId, lockTime, expiryHeight, scriptSigs) {
289
+ const parts = [];
290
+ // Header
291
+ const header = Buffer.alloc(4);
292
+ // v5: version field encodes (version | fOverwintered flag)
293
+ // fOverwintered = 1 << 31
294
+ writeU32LE(header, (TX_VERSION | (1 << 31)) >>> 0, 0);
295
+ parts.push(header);
296
+ // nVersionGroupId
297
+ const vgid = Buffer.alloc(4);
298
+ writeU32LE(vgid, TX_VERSION_GROUP_ID, 0);
299
+ parts.push(vgid);
300
+ // nConsensusBranchId
301
+ parts.push(branchIdBytes(branchId));
302
+ // nLockTime
303
+ const lt = Buffer.alloc(4);
304
+ writeU32LE(lt, lockTime, 0);
305
+ parts.push(lt);
306
+ // nExpiryHeight
307
+ const eh = Buffer.alloc(4);
308
+ writeU32LE(eh, expiryHeight, 0);
309
+ parts.push(eh);
310
+ // Transparent bundle
311
+ // tx_in_count
312
+ parts.push(compactSize(inputs.length));
313
+ // tx_in
314
+ for (let i = 0; i < inputs.length; i++) {
315
+ const inp = inputs[i];
316
+ // prevout
317
+ const outpoint = Buffer.alloc(36);
318
+ inp.prevTxid.copy(outpoint, 0);
319
+ writeU32LE(outpoint, inp.prevIndex, 32);
320
+ parts.push(outpoint);
321
+ // scriptSig
322
+ const sig = scriptSigs ? scriptSigs[i] : Buffer.alloc(0);
323
+ parts.push(compactSize(sig.length));
324
+ if (sig.length > 0)
325
+ parts.push(sig);
326
+ // sequence
327
+ const seq = Buffer.alloc(4);
328
+ writeU32LE(seq, inp.sequence, 0);
329
+ parts.push(seq);
330
+ }
331
+ // tx_out_count
332
+ parts.push(compactSize(outputs.length));
333
+ // tx_out
334
+ for (const out of outputs) {
335
+ const valueBuf = Buffer.alloc(8);
336
+ writeI64LE(valueBuf, out.value, 0);
337
+ parts.push(valueBuf);
338
+ parts.push(compactSize(out.script.length));
339
+ parts.push(out.script);
340
+ }
341
+ // Sapling bundle (empty)
342
+ parts.push(compactSize(0)); // nSpendsSapling
343
+ parts.push(compactSize(0)); // nOutputsSapling
344
+ // Orchard bundle (empty)
345
+ parts.push(Buffer.from([0x00])); // nActionsOrchard = 0
346
+ return Buffer.concat(parts);
347
+ }
348
+ /**
349
+ * Fetch UTXOs for a transparent address from Zebra RPC.
350
+ * Uses getaddressutxos (requires Zebra with -indexer flag).
351
+ */
352
+ export async function fetchUTXOs(zebraRpcUrl, tAddress) {
353
+ const resp = await fetch(zebraRpcUrl, {
354
+ method: "POST",
355
+ headers: { "Content-Type": "application/json" },
356
+ body: JSON.stringify({
357
+ jsonrpc: "2.0",
358
+ id: 1,
359
+ method: "getaddressutxos",
360
+ params: [{ addresses: [tAddress] }],
361
+ }),
362
+ });
363
+ if (!resp.ok) {
364
+ throw new Error(`Zebra RPC error: ${resp.status} ${resp.statusText}`);
365
+ }
366
+ const data = (await resp.json());
367
+ if (data.error) {
368
+ throw new Error(`Zebra RPC: ${data.error.message || JSON.stringify(data.error)}`);
369
+ }
370
+ const utxos = (data.result || []).map((u) => ({
371
+ txid: u.txid,
372
+ outputIndex: u.outputIndex ?? u.vout ?? u.index,
373
+ script: u.script ?? u.scriptPubKey,
374
+ satoshis: u.satoshis ?? u.value ?? u.amount,
375
+ }));
376
+ return utxos;
377
+ }
378
+ /**
379
+ * Select UTXOs to cover the target amount + fee.
380
+ * Simple largest-first selection. Returns selected UTXOs and total value.
381
+ */
382
+ export function selectUTXOs(utxos, targetAmount, fee) {
383
+ const needed = targetAmount + fee;
384
+ // Sort descending by value
385
+ const sorted = [...utxos].sort((a, b) => b.satoshis - a.satoshis);
386
+ const selected = [];
387
+ let total = 0;
388
+ for (const u of sorted) {
389
+ selected.push(u);
390
+ total += u.satoshis;
391
+ if (total >= needed)
392
+ break;
393
+ }
394
+ if (total < needed) {
395
+ throw new Error(`Insufficient funds: have ${total} zatoshis, need ${needed} (${targetAmount} + ${fee} fee)`);
396
+ }
397
+ return { selected, totalInput: total };
398
+ }
399
+ /**
400
+ * Build an unsigned Zcash v5 transparent transaction.
401
+ *
402
+ * Returns the unsigned serialized TX and per-input sighashes
403
+ * that need to be signed via MPC.
404
+ */
405
+ export function buildUnsignedTx(utxos, recipient, amount, fee = 10000, changeAddress, branchId = BRANCH_ID.NU5) {
406
+ if (utxos.length === 0)
407
+ throw new Error("No UTXOs provided");
408
+ if (amount <= 0)
409
+ throw new Error("Amount must be positive");
410
+ // Build inputs
411
+ const inputs = utxos.map((u) => ({
412
+ prevTxid: reverseTxid(u.txid),
413
+ prevIndex: u.outputIndex,
414
+ script: Buffer.from(u.script, "hex"),
415
+ value: u.satoshis,
416
+ sequence: 0xffffffff,
417
+ }));
418
+ // Build outputs
419
+ const totalInput = utxos.reduce((s, u) => s + u.satoshis, 0);
420
+ const change = totalInput - amount - fee;
421
+ const outputs = [
422
+ { value: amount, script: scriptFromAddress(recipient) },
423
+ ];
424
+ if (change > 0) {
425
+ // Dust threshold: skip change if below 546 zatoshis
426
+ if (change >= 546) {
427
+ outputs.push({ value: change, script: scriptFromAddress(changeAddress) });
428
+ }
429
+ }
430
+ else if (change < 0) {
431
+ throw new Error(`UTXOs total ${totalInput} < amount ${amount} + fee ${fee}`);
432
+ }
433
+ const lockTime = 0;
434
+ const expiryHeight = 0;
435
+ // Serialize unsigned TX
436
+ const unsignedTx = serializeTx(inputs, outputs, branchId, lockTime, expiryHeight);
437
+ // Compute per-input sighashes
438
+ const sighashes = [];
439
+ for (let i = 0; i < inputs.length; i++) {
440
+ const sh = transparentSighash(inputs, outputs, branchId, lockTime, expiryHeight, i, SIGHASH_ALL);
441
+ sighashes.push(sh);
442
+ }
443
+ // Compute txid (hash of unsigned TX structure per ZIP 244)
444
+ const txid = txidDigest(inputs, outputs, branchId, lockTime, expiryHeight);
445
+ return { unsignedTx, sighashes, txid };
446
+ }
447
+ /**
448
+ * Build a P2PKH scriptSig from a DER signature and compressed pubkey.
449
+ *
450
+ * Format: <sig_length> <DER_sig + SIGHASH_ALL_byte> <pubkey_length> <compressed_pubkey>
451
+ */
452
+ function buildScriptSig(derSig, pubkey) {
453
+ // Append SIGHASH_ALL byte to signature
454
+ const sigWithHashType = Buffer.concat([derSig, Buffer.from([SIGHASH_ALL])]);
455
+ const parts = [
456
+ Buffer.from([sigWithHashType.length]),
457
+ sigWithHashType,
458
+ Buffer.from([pubkey.length]),
459
+ pubkey,
460
+ ];
461
+ return Buffer.concat(parts);
462
+ }
463
+ /**
464
+ * Attach MPC signatures to an unsigned transaction.
465
+ *
466
+ * Takes the original UTXO list (to reconstruct inputs/outputs),
467
+ * DER-encoded signatures from MPC, and the compressed pubkey.
468
+ * Returns hex-encoded signed transaction ready for broadcast.
469
+ */
470
+ export function attachSignatures(utxos, recipient, amount, fee, changeAddress, signatures, pubkey, branchId = BRANCH_ID.NU5) {
471
+ if (signatures.length !== utxos.length) {
472
+ throw new Error(`Expected ${utxos.length} signatures, got ${signatures.length}`);
473
+ }
474
+ if (pubkey.length !== 33) {
475
+ throw new Error(`Expected 33-byte compressed pubkey, got ${pubkey.length}`);
476
+ }
477
+ // Rebuild inputs/outputs (same as buildUnsignedTx)
478
+ const inputs = utxos.map((u) => ({
479
+ prevTxid: reverseTxid(u.txid),
480
+ prevIndex: u.outputIndex,
481
+ script: Buffer.from(u.script, "hex"),
482
+ value: u.satoshis,
483
+ sequence: 0xffffffff,
484
+ }));
485
+ const totalInput = utxos.reduce((s, u) => s + u.satoshis, 0);
486
+ const change = totalInput - amount - fee;
487
+ const outputs = [
488
+ { value: amount, script: scriptFromAddress(recipient) },
489
+ ];
490
+ if (change >= 546) {
491
+ outputs.push({ value: change, script: scriptFromAddress(changeAddress) });
492
+ }
493
+ // Build scriptSigs
494
+ const scriptSigs = signatures.map((sig) => buildScriptSig(sig, pubkey));
495
+ // Serialize signed TX
496
+ const signedTx = serializeTx(inputs, outputs, branchId, 0, 0, scriptSigs);
497
+ return signedTx.toString("hex");
498
+ }
499
+ /**
500
+ * Broadcast a signed transaction via Zebra RPC.
501
+ * Returns the txid on success.
502
+ */
503
+ export async function broadcastTx(zebraRpcUrl, txHex) {
504
+ const resp = await fetch(zebraRpcUrl, {
505
+ method: "POST",
506
+ headers: { "Content-Type": "application/json" },
507
+ body: JSON.stringify({
508
+ jsonrpc: "2.0",
509
+ id: 1,
510
+ method: "sendrawtransaction",
511
+ params: [txHex],
512
+ }),
513
+ });
514
+ if (!resp.ok) {
515
+ throw new Error(`Zebra RPC error: ${resp.status} ${resp.statusText}`);
516
+ }
517
+ const data = (await resp.json());
518
+ if (data.error) {
519
+ throw new Error(`Broadcast failed: ${data.error.message || JSON.stringify(data.error)}`);
520
+ }
521
+ return data.result;
522
+ }
523
+ /**
524
+ * Estimate fee for a transparent P2PKH transaction.
525
+ *
526
+ * ZIP 317 marginal fee: max(grace_actions, logical_actions) * 5000
527
+ * For simple P2PKH: 1 input + 2 outputs = 2 logical actions = 10000 zatoshis
528
+ * Each additional input adds 1 logical action = +5000 zatoshis
529
+ */
530
+ export function estimateFee(numInputs, numOutputs) {
531
+ const logicalActions = Math.max(numInputs, numOutputs);
532
+ const graceActions = 2;
533
+ return Math.max(graceActions, logicalActions) * 5000;
534
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frontiercompute/zcash-ika",
3
- "version": "0.2.0",
4
- "description": "Split-key Zcash custody via Ika dWallet. secp256k1 MPC for transparent, hybrid model for shielded.",
3
+ "version": "0.3.0",
4
+ "description": "Split-key custody for Zcash, Bitcoin, and EVM. 2PC-MPC signing, on-chain spend policy, transparent TX builder, ZAP1 attestation.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "scripts": {
@@ -11,18 +11,38 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@ika.xyz/sdk": "^0.3.1",
14
- "@mysten/sui": "^2.5.0"
14
+ "@mysten/sui": "^2.5.0",
15
+ "blakejs": "^1.2.1"
15
16
  },
16
17
  "devDependencies": {
17
18
  "@types/node": "^25.5.2",
18
19
  "typescript": "^5.4.0"
19
20
  },
20
- "files": ["dist/index.js", "dist/index.d.ts", "dist/hybrid.js", "dist/hybrid.d.ts"],
21
+ "files": [
22
+ "dist/index.js",
23
+ "dist/index.d.ts",
24
+ "dist/tx-builder.js",
25
+ "dist/tx-builder.d.ts",
26
+ "dist/hybrid.js",
27
+ "dist/hybrid.d.ts"
28
+ ],
21
29
  "repository": {
22
30
  "type": "git",
23
31
  "url": "https://github.com/Frontier-Compute/zcash-ika.git"
24
32
  },
25
33
  "author": "zk_nd3r <zk_nd3r@frontiercompute.io>",
26
- "keywords": ["zcash", "ika", "dwallet", "mpc", "custody", "bitcoin", "split-key"],
34
+ "keywords": [
35
+ "zcash",
36
+ "ika",
37
+ "dwallet",
38
+ "mpc",
39
+ "custody",
40
+ "bitcoin",
41
+ "split-key",
42
+ "policy",
43
+ "sui-move",
44
+ "transparent-tx",
45
+ "attestation"
46
+ ],
27
47
  "license": "MIT"
28
48
  }