@paralock/zcash
Reference for the browser-safe Zcash package: keys and addresses, HTLCs, 2-of-2 multisig locks, v5 transactions, ZIP 244 signature hashes, ZIP 317 fees and the node interface.
@paralock/zcash builds the Zcash side of a Paralock swap in pure TypeScript: sha256 HTLCs, 2-of-2 multisig locks with pre-signed time-locked refunds, v5 transactions with ZIP 244 signature hashes (checked against the official test vectors), ZIP 317 fees, and a Zebra or Zakura JSON-RPC client.
It is browser-safe: no Node built-ins. Its only dependencies are @noble/hashes, @noble/curves and @scure/base.
Install
npm install @paralock/zcashVersion 0.2.0. Conventions:
- Amounts are
bigintzatoshi (1 ZEC = 100,000,000 zat). - Txids are display-order hex without
0x. - Functions that take
Uint8Array | stringaccept bytes or hex, with or without0x. Returned hex (pubkeys, hashlocks inHtlc.params) is lowercase without0x. Networkis"mainnet" | "testnet" | "regtest". Regtest shares testnet's address prefixes.- Heights and lock times are block heights, never timestamps.
Keys and addresses
| Export | Signature | Meaning |
|---|---|---|
KeyPair | { private | 32-byte secret scalar and 33-byte compressed public key. |
randomKeyPair | random | A fresh key pair from a CSPRNG. |
keyPairFromPrivate | key | The pair of a 32-byte private key. Throws if the scalar is out of range. |
isValidPublicKey | is | True for a 33-byte compressed point on the curve. |
assertValidPublicKey | assert | Throws unless is. |
keyAddress | key | The key's P2PKH address. |
keyScriptPubKey | key | The key's P2PKH scriptPubKey. |
signDigest | sign | Low-S, strict-DER ECDSA over a 32-byte digest, with the sighash type byte appended. RFC 6979 deterministic nonces. |
p2pkhAddress | p2pkh | P2PKH address of a compressed public key. |
p2shAddress | p2sh | P2SH address of a redeem script. |
encodeP2pkh, encodeP2sh | ( | Base58check encoding of a 20-byte hash. |
decodeTransparentAddress | decode | { kind: "p2pkh" |. Testnet and regtest addresses report network: "testnet". Throws on a bad checksum, length or prefix. |
isTransparentAddress | is | Valid transparent address, for network when given. |
scriptPubKeyForAddress | script | The scriptPubKey paying address. With network, a mainnet address can't slip into a testnet transaction (or the reverse). |
| Network | P2PKH prefix | P2SH prefix |
|---|---|---|
mainnet | t1… | t3… |
testnet, regtest | tm… | t2… |
HTLC
The script
The HTLC is a P2SH output. Its redeem script puts the claim branch first, with a sha256 hashlock and a 32-byte size check:
OP_IF
OP_SIZE 32 OP_EQUALVERIFY OP_SHA256 <hashlock> OP_EQUALVERIFY <claimPubkey>
OP_ELSE
<refundHeight> OP_CHECKLOCKTIMEVERIFY OP_DROP <refundPubkey>
OP_ENDIF
OP_CHECKSIG- Claim:
<sig> <secret> OP_1 <redeemScript>. Works before and after the timeout. - Refund:
<sig> OP_0 <redeemScript>, withlock_time = refundHeightand a non-final input sequence. It can be mined once the tip reachesrefundHeight(in blockrefundHeight + 1or later).
The same hashlock (sha256 of the secret) locks the EVM side, see ParalockHTLC.
Creating and parsing
| Export | Signature | Meaning |
|---|---|---|
HtlcParams | { hashlock; claim | hashlock is 32 bytes; both keys are compressed and must differ; refund is an absolute block height. |
Htlc | { params; redeem | params holds lowercase hex (hashlock, claim, refund) and refund. |
createHtlc | create | Builds the HTLC. Both keys must be valid curve points. |
htlcFromRedeemScript | htlc | Parses and validates a redeem script against the template, byte for byte. This is how a maker checks a taker's lock. |
parseHtlcRedeemScript | parse | The parameters of a redeem script. Throws "not a Paralock HTLC script" unless it rebuilds to the same bytes. |
htlcRedeemScript | htlc | The raw redeem script. |
extractPreimage | extract | The 32-byte secret, if script is a claim of an HTLC with this hashlock. |
claimScriptSig, refundScriptSig | The two scriptSigs, size-checked. | |
PREIMAGE_SIZE | 32 | The secret is exactly 32 bytes (enforced by OP_). |
Building transactions
Every builder returns a SignedTx:
| Field | Type | Meaning |
|---|---|---|
tx | Transparent | The signed transaction. |
hex | string | Raw hex for sendrawtransaction. |
txid | string | Display-order txid. Final before signing: v5 txids (ZIP 244) don't commit to signatures. |
fee | bigint | Fee paid, in zatoshi. |
They take a Utxo ({ txid: string; vout: number; value: bigint; scriptPubKey: Uint8Array | string }) and the shared TxOptions:
| Option | Type | Default | Meaning |
|---|---|---|---|
branchId | number | required | Consensus branch id of the next block. Read it from the node (get). There is no default: signatures commit to it, and a chain on another upgrade (e.g. a testnet on NU7) uses another id. |
expiryHeight | number | 0 (never expires) | Last block the transaction may be mined in. Nodes reject transactions that expire within 3 blocks of the tip. |
fee | bigint | ZIP 317 | Override the fee, in zatoshi. Must be at least 0 and below the value spent. |
network | Network | required | Every address is checked against it. |
| Builder | Arguments (plus TxOptions) | Behavior |
|---|---|---|
buildHtlcFunding | { htlc, | Funds the HTLC as output 0 from P2PKH UTXOs owned by key. Change, when any, is output 1. |
buildHtlcClaim | { htlc, | Claims with the secret. key must be the claim key, and sha256( must equal the hashlock (checked before signing). Pays utxo. minus the fee to to. |
buildHtlcRefund | { htlc, | Refunds after the timeout. key must be the refund key. |
buildP2pkhSpend | { utxos, | Spends every given UTXO into outputs. Change goes to change (default: the key's address); change below MIN_ goes to the fee. |
Builders throw on a missing or malformed branchId or network, insufficient funds, a UTXO the key doesn't own, a duplicate UTXO, a UTXO value outside (0, MAX_MONEY], an output below DUST_LIMIT, a negative fee override or one not below the value spent, or an amount that doesn't cover the fee.
End-to-end HTLC example
The taker locks 0.1 ZEC for the maker. The maker claims with the secret, or the taker refunds after 144 blocks. Anyone can read the secret from the claim.
import {
FUNDING_EXPIRY_DELTA,
ZcashRpc,
buildHtlcClaim,
buildHtlcFunding,
buildHtlcRefund,
createHtlc,
extractPreimage,
keyAddress,
keyPairFromPrivate,
} from "@paralock/zcash";
const network = "testnet";
const rpc = new ZcashRpc("http://127.0.0.1:18232", { timeoutMs: 15_000, retries: 3 });
const taker = keyPairFromPrivate(process.env.TAKER_KEY!); // funds the lock; can refund it
const maker = keyPairFromPrivate(process.env.MAKER_KEY!); // claims it with the secret
// 1. A 32-byte secret. Its sha256 is the hashlock.
const secret = crypto.getRandomValues(new Uint8Array(32));
const hashlock = new Uint8Array(await crypto.subtle.digest("SHA-256", secret));
// 2. The HTLC: the maker claims with the secret, the taker refunds from block tip + 144.
const tip = await rpc.getBlockCount();
const branchId = await rpc.getNextBranchId(); // always sign with the node's next branch id
const htlc = createHtlc(
{ hashlock, claimPubkey: maker.publicKey, refundPubkey: taker.publicKey, refundHeight: tip + 144 },
network,
);
// 3. Fund it from the taker's UTXOs. The funding expires if it isn't mined within 40 blocks.
const amount = 10_000_000n; // 0.1 ZEC
const funding = buildHtlcFunding({
htlc,
amount,
utxos: await rpc.getAddressUtxos([keyAddress(taker, network)]), // all of them are spent
key: taker,
branchId,
network,
expiryHeight: tip + FUNDING_EXPIRY_DELTA,
});
await rpc.sendRawTransaction(funding.hex);
const utxo = { txid: funding.txid, vout: 0, value: amount, scriptPubKey: htlc.scriptPubKey };
// 4a. The maker claims with the secret. The fee (10,000 zat) comes out of the HTLC.
const claim = buildHtlcClaim({ htlc, utxo, key: maker, preimage: secret, to: keyAddress(maker, network), branchId, network });
await rpc.sendRawTransaction(claim.hex);
// 4b. Or, once the tip reaches refundHeight, the taker refunds.
const refund = buildHtlcRefund({ htlc, utxo, key: taker, to: keyAddress(taker, network), branchId, network });
// 5. Anyone can read the secret from the confirmed claim.
const spend = await rpc.findSpend(htlc.address, funding.txid, 0);
const revealed = spend && extractPreimage(spend.vin[0]!.scriptSig!.hex, htlc.params.hashlock);2-of-2 multisig
Private (adaptor) mode locks ZEC in a plain 2-of-2 multisig: OP_2 <pkA> <pkB> OP_2 OP_CHECKMULTISIG with BIP 67 sorted keys. On-chain it looks like any multisig: no hashlock, no timelock. The refund path is a pre-signed transaction with a lock_time.
| Export | Signature | Meaning |
|---|---|---|
Multisig2of2 | { pubkeys: [string, | pubkeys are sorted lowercase hex, in redeem-script order. |
createMultisig2of2 | create | Sorts the keys. Both must be valid and different. |
multisigFromRedeemScript | multisig | Parses and validates against the sorted template, byte for byte. Throws on unsorted keys. |
MultisigSpend | { multisig; tx; sighash; fee; prev | An unsigned spend and the ZIP 244 SIGHASH_ digest both keys sign. |
buildMultisigSpend | build | Spends the output to one address, minus the fee. With lock (a block height) it becomes a refund that can't be mined before that height. |
signMultisigSpend | sign | One key's signature: DER plus the SIGHASH_ byte. |
verifyMultisigSignature | verify | Checks a counterparty's signature (low-S, SIGHASH_), for example the maker's refund signature. False unless pubkey is one of the 2-of-2's two keys. |
finalizeMultisigSpend | finalize | Assembles the scriptSig. signatures is keyed by compressed pubkey hex (lowercase, no 0x). Both signatures are always verified first. |
multisigSignaturesFromScriptSig | multisig | The two signatures of a confirmed spend, in redeem-script key order. |
A signature can come from anywhere, for example one completed from an adaptor pre-signature with der(sig) plus the sighash byte.
import {
bytesToHex,
buildMultisigSpend,
createMultisig2of2,
finalizeMultisigSpend,
signMultisigSpend,
verifyMultisigSignature,
} from "@paralock/zcash";
const multisig = createMultisig2of2(alice.publicKey, bob.publicKey, "testnet");
const utxo = { txid: fundingTxid, vout: 0, value: 10_000_000n, scriptPubKey: multisig.scriptPubKey };
// Alice's time-locked refund, signed by Bob BEFORE Alice funds the multisig.
const refund = buildMultisigSpend({ multisig, utxo, to: aliceAddress, lockTime: refundHeight, branchId, network: "testnet" });
const bobSig = signMultisigSpend(refund, bob);
if (!verifyMultisigSignature(refund, bob.publicKey, bobSig)) throw new Error("bad refund signature: don't fund");
// After refundHeight, Alice completes and broadcasts it.
const signed = finalizeMultisigSpend(refund, {
[bytesToHex(alice.publicKey)]: signMultisigSpend(refund, alice),
[bytesToHex(bob.publicKey)]: bobSig,
});This is safe because the funding txid is final before anyone signs it: v5 txids don't commit to signatures.
Transactions
Paralock builds transparent-only v5 transactions (ZIP 225).
| Export | Signature | Meaning |
|---|---|---|
TransparentTx | { branch | A transparent-only v5 transaction. |
TxIn | { prev | prev is display-order hex. |
TxOut | { value: bigint; script | value in zatoshi. |
PrevOut | { value: bigint; script | The output an input spends, needed for signature hashes. |
serializeTx | serialize | v5 serialization with empty Sapling and Orchard bundles. |
parseTx | parse | Parses the header and transparent bundle of any v5 transaction. Shielded bundles are returned undecoded in shielded. Throws for other versions. |
txid | txid( | ZIP 244 txid, display order. shielded defaults to the empty-bundle digests. |
transparentTxid | transparent | The txid of a raw transparent-only v5 transaction, or undefined if it has shielded parts or doesn't parse. |
serializeOutpoint, serializeTxOut, headerBytes | Serialization pieces. |
Signature hashes (ZIP 244)
function transparentSighash(
tx: TransparentTx,
index: number,
prevouts: PrevOut[],
hashType: number = SIGHASH_ALL,
shielded?: ShieldedDigests,
): Uint8ArrayThe ZIP 244 signature digest for transparent input index. prevouts lists the value and scriptPubKey of every spent output, in input order. For a P2SH input the scriptPubKey is the P2SH script, not the redeem script. All six sighash types are supported and checked against the official vectors; Paralock uses SIGHASH_ALL everywhere.
| Constant | Value |
|---|---|
SIGHASH_ALL | 0x01 |
SIGHASH_NONE | 0x02 |
SIGHASH_SINGLE | 0x03 |
SIGHASH_ANYONECANPAY | 0x80 (modifier) |
The digest building blocks are exported too: blake2b256(personalization, data), headerDigest, prevoutsDigest, sequenceDigest, outputsDigest, transparentTxidDigest, emptySaplingDigest, emptyOrchardDigest, txidPersonalization(branchId) and the ShieldedDigests type.
Fees (ZIP 317)
function zip317Fee(tx: Pick<TransparentTx, "inputs" | "outputs">): bigintThe ZIP 317 conventional fee of a transparent-only transaction, in zatoshi:
5000 × max(2, max(ceil(inputBytes / 150), ceil(outputBytes / 34)))Inputs are sized with their final scriptSig, so pass a transaction with placeholder signatures of realistic length. The builders use 73-byte placeholders (the DER maximum plus the sighash byte), so the fee never comes out short. A one-input, one-output HTLC or 2-of-2 spend costs 10,000 zat.
| Constant | Value | Meaning |
|---|---|---|
MARGINAL_FEE | 5_ | Zatoshi per logical action. |
GRACE_ACTIONS | 2 | Every transaction pays for at least this many actions. |
P2PKH_STANDARD_INPUT_SIZE | 150 | Bytes. |
P2PKH_STANDARD_OUTPUT_SIZE | 34 | Bytes. |
Constants and limits
| Constant | Value | Meaning |
|---|---|---|
BRANCH_ID_NU6_3 | 0x37a5165b | Consensus branch id of NU6.3 ("Ironwood"), for reference. No builder defaults to it: pass the node's live value. |
TX_VERSION_5 | 5 | Transaction version. |
V5_VERSION_GROUP_ID | 0x26a7270a | v5 version group id. |
OVERWINTERED_FLAG | 0x80000000 | Header flag. |
SEQUENCE_FINAL | 0xffffffff | lock_ is ignored for this input. |
SEQUENCE_ENABLE_LOCKTIME | 0xfffffffe | Non-final: lock_ and OP_ are enforced. Used by refunds. |
DUST_LIMIT | 1_ | Outputs below this many zatoshi are refused. |
MIN_CHANGE | 1_ | Change below this goes to the fee. Same as DUST_. |
MAX_MONEY | 2_ | 21 million ZEC in zatoshi. |
FUNDING_EXPIRY_DELTA | 40 | Blocks. Paralock's funding transactions expire at tip + 40. |
MAX_SCRIPT_ELEMENT_SIZE | 520 | Bytes: the largest push, so the largest P2SH redeem script. |
MAX_STANDARD_SCRIPTSIG_SIZE | 1650 | Bytes: larger scriptSigs are not relayed. |
assertOutputValue(value, what?), assertExpiryHeight(expiryHeight) and assertP2shSizes(redeemScript, scriptSig) throw when a value breaks these limits.
The ZcashNode interface
ZcashNode is the backend the SDK engines depend on. ZcashRpc implements all of it. Light backends such as LightwalletdClient implement the required methods and may omit the optional ones.
| Method | Required | Meaning |
|---|---|---|
getBlockCount( | yes | Height of the chain tip. |
getNextBranchId( | yes | Consensus branch id the next block uses. Sign with it. |
getAddressUtxos( | yes | Confirmed UTXOs of transparent addresses, with the height they were mined at. |
getAddressTxids( | yes | Confirmed txids touching transparent addresses. |
getRawTransaction( | yes | A mempool or mined transaction. Rejects if unknown. |
sendRawTransaction( | yes | Broadcasts and returns the txid. Must succeed if the node already has the transaction. |
findSpend( | yes | The confirmed transaction spending txid:vout, found through address's history. |
getTxOut?( | no | The unspent output, mempool spends included; null if spent or unknown. Without it the engines query confirmed UTXOs instead. |
upgradeBefore?( | no | The first network upgrade activating after the next block and at or before height. Without it the engines skip the upgrade check, with a logged warning. |
generate?( | no | Regtest only: mines blocks. |
| Type | Fields |
|---|---|
NodeTransaction | txid, hex, height?, confirmations? (0 or absent in the mempool), vin: { txid?; vout?; coinbase?; script |
NodeTxOut | confirmations, value (in ZEC, not zatoshi), script |
UpcomingUpgrade | name, activation, branch |
ZcashRpc
class ZcashRpc implements ZcashNode {
constructor(url: string, options?: ZcashRpcOptions);
}JSON-RPC client for Zebra and Zakura nodes (and zcashd-compatible endpoints). Browser-safe: it uses fetch. Every call has a timeout. Idempotent calls are retried with exponential backoff on transport failures (network errors, timeouts, HTTP 5xx, non-JSON bodies). JSON-RPC errors are thrown at once.
| Option | Type | Default | Meaning |
|---|---|---|---|
auth | { username: string; password: string } | none | HTTP basic auth (Zebra cookie auth or rpcuser/rpcpassword). |
timeoutMs | number | 15_ | Per-request timeout, in milliseconds. |
retries | number | 3 | Extra attempts after a transport failure. Never after a JSON-RPC error. generate is never retried. |
retryDelayMs | number | 250 | First retry delay. Doubles each attempt, with jitter, capped at 5 s. |
fetch | typeof fetch | global fetch | Custom fetch implementation. |
A bare { username, password } second argument is still accepted (0.1 compatibility).
| Method | Meaning |
|---|---|
call< | Raw JSON-RPC call. |
getBlockCount( | getblockcount. |
getBlockchainInfo( | getblockchaininfo: Blockchain with chain, blocks, upgrades, consensus. |
getNextBranchId( | Parses getblockchaininfo.. |
upgradeBefore( | The first upgrade in upgrades activating after the next block and at or before height. |
getAddressUtxos( | getaddressutxos (P2PKH and P2SH). |
getAddressTxids( | getaddresstxids (confirmed). |
getTxOut( | gettxout: Tx, or null when spent or unknown. |
sendRawTransaction( | Returns the txid. Retried on transport failures (resending identical bytes is harmless). If the node already has a transparent-only transaction, that counts as success. |
getRawTransaction( | getrawtransaction <: Raw with confirmations and vout. |
getRawMempool( | Txids in the mempool. |
generate( | Regtest only: mines n blocks. Never retried. |
findSpend( | Scans address's history for the confirmed spend of txid:vout. |
Errors
| Class | Fields | When |
|---|---|---|
ZcashRpcError | method, code (the JSON-RPC error code), message, already | The node answered with a JSON-RPC error. Not retried: the request reached the node and was refused. already is true when sendrawtransaction was refused because the node already has the transaction (mempool or chain). |
ZcashRpcTransportError | method, message, cause | The node could not be reached, timed out, or didn't answer JSON-RPC, after all retries. |
import { ZcashRpcError } from "@paralock/zcash";
try {
await rpc.sendRawTransaction(hex);
} catch (err) {
if (!(err instanceof ZcashRpcError && err.alreadyKnown)) throw err; // already broadcast: fine
}Low-level helpers
| Area | Exports |
|---|---|
| Script | OP (opcodes), hash160, push, script, push, p2pkh, p2sh, decompile |
| Bytes | bytes, hex, concat, to (bytes or hex with or without 0x), u32le, i64le, compact, with, Reader, txid, bytes, is |
Security notes
- Public keys are checked to be valid compressed points. HTLC claim and refund keys must differ.
- Signatures are low-S, strict DER, with RFC 6979 nonces.
finalizeMultisigSpendverifies both signatures. - The builders require
network, so an address from the wrong network is rejected, andbranchId, so nothing is signed for the wrong upgrade. - Read the branch id from your node and check
upgradeBeforefor pre-signed spends. - Redeem scripts and scriptSigs are checked against consensus and relay size limits.
- Check counterparty scripts with
htlcFromRedeemScriptormultisigFromRedeemScript: both rebuild the template and compare bytes.