Skip to content

Instantly share code, notes, and snippets.

@nfl0
Created August 18, 2026 03:04
Show Gist options
  • Select an option

  • Save nfl0/a7bca23616b82d32e2f0c2b2a4038b32 to your computer and use it in GitHub Desktop.

Select an option

Save nfl0/a7bca23616b82d32e2f0c2b2a4038b32 to your computer and use it in GitHub Desktop.
// Browser-side implementation of Zcash Wormhole Ceremony v1.
// No external dependencies. Uses WebCrypto only for Ed25519; all transcript
// hashing, Pallas receiver derivation, F4Jumble, and Bech32m are implemented here.
export const FORMAT = 'zcash-wormhole-ceremony';
export const FORMAT_VERSION = 1;
const PERSON = new TextEncoder().encode('ZcashWormholeV1');
const PROFILE_DK = hexToBytes('31d6a685be570f9faf3ca8b052e887840b2c9f8d67224ca82aefb9e2ee5bedaf');
const PROFILE_D = hexToBytes('8ff3386971cb64b8e77899');
const PROFILE_GD_X = 0x09ceb27d1d782ab1df0bae032509c83643023ad15ad5a86e902d71da049f531bn;
const PROFILE_GD_Y = 0x24a15eeff9e1d0d672c9847da6341f8c8d154a8a48187ba55eb9df2a206c3633n;
const P = 0x40000000000000000000000000000000224698fc094cf91b992d30ed00000001n;
const Q = 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000001n;
const TYPE_ORCHARD = 0x03;
const BECH32M_CONST = 0x2bc830a3;
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const MASK64 = (1n << 64n) - 1n;
const UTF8 = new TextEncoder();
const IV = [
0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n,
0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n,
];
const SIGMA = [
[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15],
[14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3],
[11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4],
[7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8],
[9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13],
[2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9],
[12,5,1,15,14,13,4,10,0,7,6,3,9,2,8,11],
[13,11,7,14,12,1,3,9,5,0,15,4,8,6,2,10],
[6,15,14,9,11,3,0,8,12,2,13,7,1,4,10,5],
[10,2,8,4,7,6,1,5,15,11,9,14,3,12,13,0],
[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15],
[14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3],
];
function concat(...parts) {
const len = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(len);
let off = 0;
for (const p of parts) { out.set(p, off); off += p.length; }
return out;
}
function u32le(n) { const a = new Uint8Array(4); new DataView(a.buffer).setUint32(0, n, true); return a; }
function u64le(n) {
let x = BigInt(n); const a = new Uint8Array(8);
for (let i=0;i<8;i++) { a[i] = Number(x & 255n); x >>= 8n; }
return a;
}
function lp(a) { return concat(u32le(a.length), a); }
function eqBytes(a,b) { if (a.length !== b.length) return false; let d=0; for(let i=0;i<a.length;i++) d|=a[i]^b[i]; return d===0; }
function rotr(x,n) { const k=BigInt(n); return ((x >> k) | ((x << (64n-k)) & MASK64)) & MASK64; }
function add64(...xs) { let z=0n; for(const x of xs) z=(z+x)&MASK64; return z; }
function read64le(a,off) { let x=0n; for(let i=7;i>=0;i--) x=(x<<8n)|BigInt(a[off+i]); return x; }
function write64le(a,off,x) { let z=x; for(let i=0;i<8;i++){ a[off+i]=Number(z&255n); z>>=8n; } }
export function blake2b(data, digestSize=32, personal=new Uint8Array()) {
if (!(data instanceof Uint8Array)) data = new Uint8Array(data);
if (digestSize < 1 || digestSize > 64) throw new Error('invalid BLAKE2b digest size');
if (personal.length > 16) throw new Error('BLAKE2b personalization too long');
const param = new Uint8Array(64);
param[0]=digestSize; param[2]=1; param[3]=1;
param.set(personal, 48);
const h = IV.map((x,i)=>x ^ read64le(param,i*8));
const compress = (block, count, last) => {
const m = Array.from({length:16},(_,i)=>read64le(block,i*8));
const v = [...h, ...IV];
v[12] ^= BigInt(count) & MASK64;
v[13] ^= BigInt(count) >> 64n;
if (last) v[14] ^= MASK64;
const G = (a,b,c,d,x,y) => {
v[a]=add64(v[a],v[b],x); v[d]=rotr(v[d]^v[a],32);
v[c]=add64(v[c],v[d]); v[b]=rotr(v[b]^v[c],24);
v[a]=add64(v[a],v[b],y); v[d]=rotr(v[d]^v[a],16);
v[c]=add64(v[c],v[d]); v[b]=rotr(v[b]^v[c],63);
};
for(let r=0;r<12;r++) {
const s=SIGMA[r];
G(0,4,8,12,m[s[0]],m[s[1]]); G(1,5,9,13,m[s[2]],m[s[3]]);
G(2,6,10,14,m[s[4]],m[s[5]]); G(3,7,11,15,m[s[6]],m[s[7]]);
G(0,5,10,15,m[s[8]],m[s[9]]); G(1,6,11,12,m[s[10]],m[s[11]]);
G(2,7,8,13,m[s[12]],m[s[13]]); G(3,4,9,14,m[s[14]],m[s[15]]);
}
for(let i=0;i<8;i++) h[i] = h[i] ^ v[i] ^ v[i+8];
};
if (data.length === 0) compress(new Uint8Array(128), 0, true);
else {
let off=0;
while(off < data.length) {
const take = Math.min(128, data.length-off);
const block = new Uint8Array(128); block.set(data.subarray(off, off+take));
off += take; compress(block, off, off === data.length);
}
}
const full = new Uint8Array(64); h.forEach((x,i)=>write64le(full,i*8,x));
return full.subarray(0,digestSize);
}
function H(...parts) { return blake2b(concat(...parts),32,PERSON); }
export function bytesToHex(a) { return [...a].map(b=>b.toString(16).padStart(2,'0')).join(''); }
export function hexToBytes(s) {
if (typeof s !== 'string' || s.length % 2 || !/^[0-9a-fA-F]*$/.test(s)) throw new Error('invalid hex');
const a = new Uint8Array(s.length/2); for(let i=0;i<a.length;i++) a[i]=parseInt(s.slice(i*2,i*2+2),16); return a;
}
function bigintFromLE(a) { let x=0n; for(let i=a.length-1;i>=0;i--) x=(x<<8n)|BigInt(a[i]); return x; }
function bigintToLE(x,len) { const a=new Uint8Array(len); let z=x; for(let i=0;i<len;i++){a[i]=Number(z&255n);z>>=8n;} return a; }
function utf8Limited(s,max,label) { if(typeof s!=='string'||!s) throw new Error(`${label} is required`); const b=UTF8.encode(s); if(b.length>max) throw new Error(`${label} exceeds ${max} bytes`); return b; }
export function genesisHash(ceremonyId, name) { return H(UTF8.encode('genesis\0'), ceremonyId, lp(utf8Limited(name,256,'name'))); }
export function contributionMessage(ceremonyId, sequence, parent, participant, pub, entropy) {
if (pub.length!==32 || entropy.length!==32 || parent.length!==32 || ceremonyId.length!==16) throw new Error('invalid contribution lengths');
return concat(UTF8.encode('contribution\0'), ceremonyId, u64le(sequence), parent, lp(utf8Limited(participant,128,'participant')), pub, entropy);
}
export function stateHash(parent, message, signature) { if(signature.length!==64) throw new Error('invalid signature length'); return H(UTF8.encode('state\0'), parent, lp(message), signature); }
async function importEd25519Public(raw) { return crypto.subtle.importKey('raw', raw, {name:'Ed25519'}, false, ['verify']); }
export async function supportsEd25519() {
try { const kp=await crypto.subtle.generateKey({name:'Ed25519'}, false, ['sign','verify']); await crypto.subtle.sign({name:'Ed25519'}, kp.privateKey, new Uint8Array([1])); return true; } catch { return false; }
}
export async function verifyCeremony(doc) {
if (!doc || doc.format!==FORMAT || doc.version!==FORMAT_VERSION) throw new Error('unsupported ceremony format/version');
const cid=hexToBytes(doc.ceremony_id); if(cid.length!==16) throw new Error('invalid ceremony_id');
const gen=hexToBytes(doc.genesis_hash); const expectedGen=genesisHash(cid,doc.name); if(!eqBytes(gen,expectedGen)) throw new Error('genesis_hash mismatch');
if(!Array.isArray(doc.contributions)) throw new Error('contributions must be an array');
let state=gen;
for(let i=0;i<doc.contributions.length;i++) {
const r=doc.contributions[i], seq=i+1;
if(r.sequence!==seq) throw new Error(`contribution ${seq}: sequence mismatch`);
const parent=hexToBytes(r.parent_hash); if(!eqBytes(parent,state)) throw new Error(`contribution ${seq}: parent_hash mismatch`);
const pub=hexToBytes(r.public_key), entropy=hexToBytes(r.entropy), sig=hexToBytes(r.signature), claimed=hexToBytes(r.state_hash);
const msg=contributionMessage(cid,seq,parent,r.participant,pub,entropy);
const key=await importEd25519Public(pub);
const ok=await crypto.subtle.verify({name:'Ed25519'},key,sig,msg); if(!ok) throw new Error(`contribution ${seq}: invalid Ed25519 signature`);
const next=stateHash(parent,msg,sig); if(!eqBytes(next,claimed)) throw new Error(`contribution ${seq}: state_hash mismatch`); state=next;
}
return state;
}
export async function makeContribution(doc, participant) {
const parent=await verifyCeremony(doc); const cid=hexToBytes(doc.ceremony_id); const sequence=doc.contributions.length+1;
const entropy=crypto.getRandomValues(new Uint8Array(32));
const kp=await crypto.subtle.generateKey({name:'Ed25519'}, false, ['sign','verify']);
const pub=new Uint8Array(await crypto.subtle.exportKey('raw',kp.publicKey));
const msg=contributionMessage(cid,sequence,parent,participant,pub,entropy);
const sig=new Uint8Array(await crypto.subtle.sign({name:'Ed25519'},kp.privateKey,msg));
const next=stateHash(parent,msg,sig);
return {sequence,parent_hash:bytesToHex(parent),participant,public_key:bytesToHex(pub),entropy:bytesToHex(entropy),signature:bytesToHex(sig),state_hash:bytesToHex(next)};
}
function mod(x,m=P) { x%=m; return x<0n?x+m:x; }
function modPow(base,exp,m) { let b=mod(base,m), e=exp, r=1n; while(e){if(e&1n)r=(r*b)%m;b=(b*b)%m;e>>=1n;} return r; }
function inv(x) { return modPow(mod(x),P-2n,P); }
// Jacobian Pallas point: affine x=X/Z^2, y=Y/Z^3. Z=0 is identity.
function jdbl(a) {
if(a.z===0n || a.y===0n) return {x:0n,y:1n,z:0n};
const A=mod(a.x*a.x), B=mod(a.y*a.y), C=mod(B*B);
const D=mod(2n*(mod((a.x+B)*(a.x+B))-A-C));
const E=mod(3n*A), F=mod(E*E);
return {x:mod(F-2n*D), y:mod(E*(D-mod(F-2n*D))-8n*C), z:mod(2n*a.y*a.z)};
}
function jadd(a,b) {
if(a.z===0n) return b; if(b.z===0n) return a;
const z1z1=mod(a.z*a.z), z2z2=mod(b.z*b.z);
const u1=mod(a.x*z2z2), u2=mod(b.x*z1z1);
const s1=mod(a.y*b.z*z2z2), s2=mod(b.y*a.z*z1z1);
if(u1===u2) return s1===s2 ? jdbl(a) : {x:0n,y:1n,z:0n};
const h=mod(u2-u1), i=mod((2n*h)*(2n*h)), j=mod(h*i), r=mod(2n*(s2-s1)), v=mod(u1*i);
const x3=mod(r*r-j-2n*v), y3=mod(r*(v-x3)-2n*s1*j), z3=mod(((a.z+b.z)*(a.z+b.z)-z1z1-z2z2)*h);
return {x:x3,y:y3,z:z3};
}
function pointMul(s, affine) {
let n=s%Q, acc={x:0n,y:1n,z:0n}, base={x:affine.x,y:affine.y,z:1n};
while(n){ if(n&1n) acc=jadd(acc,base); base=jdbl(base); n>>=1n; } return acc;
}
function toAffine(a) { if(a.z===0n) return null; const zi=inv(a.z), z2=mod(zi*zi), z3=mod(z2*zi); return {x:mod(a.x*z2),y:mod(a.y*z3)}; }
function encodePoint(a) { const p=toAffine(a); if(!p) return new Uint8Array(32); const out=bigintToLE(p.x,32); if(p.y&1n) out[31]|=0x80; return out; }
function deriveIvk(finalState) {
for(let c=0;c<0xffffffff;c++) { const candidate=H(UTF8.encode('ivk\0'),finalState,u32le(c)); const n=bigintFromLE(candidate); if(n>0n&&n<P)return candidate; }
throw new Error('IVK rejection sampling exhausted');
}
function compactSize(n) { if(n<253)return new Uint8Array([n]); if(n<=0xffff)return concat(new Uint8Array([253]),bigintToLE(BigInt(n),2)); if(n<=0xffffffff)return concat(new Uint8Array([254]),bigintToLE(BigInt(n),4)); return concat(new Uint8Array([255]),bigintToLE(BigInt(n),8)); }
function xor(a,b) { const o=new Uint8Array(a.length); for(let i=0;i<a.length;i++)o[i]=a[i]^b[i]; return o; }
function f4jumble(data) {
if(data.length<48||data.length>4194368) throw new Error('F4Jumble length out of range');
const lL=Math.min(64,Math.floor(data.length/2)), lR=data.length-lL, a=data.subarray(0,lL), b=data.subarray(lL);
const h=(i,u)=>blake2b(u,lL,concat(UTF8.encode('UA_F4Jumble_H'),new Uint8Array([i,0,0])));
const g=(i,u)=>{ const parts=[]; for(let j=0;j<Math.ceil(lR/64);j++)parts.push(blake2b(u,64,concat(UTF8.encode('UA_F4Jumble_G'),new Uint8Array([i]),bigintToLE(BigInt(j),2)))); return concat(...parts).subarray(0,lR); };
const x=xor(b,g(0,a)), y=xor(a,h(0,x)), d=xor(x,g(1,y)), c=xor(y,h(1,d)); return concat(c,d);
}
function convertBits(data,fromBits,toBits,pad=true) { let acc=0,bits=0; const out=[], maxv=(1<<toBits)-1, maxacc=(1<<(fromBits+toBits-1))-1; for(const value of data){if(value<0||(value>>fromBits))throw new Error('invalid bit conversion'); acc=((acc<<fromBits)|value)&maxacc;bits+=fromBits;while(bits>=toBits){bits-=toBits;out.push((acc>>bits)&maxv);}} if(pad&&bits)out.push((acc<<(toBits-bits))&maxv); return out; }
function polymod(values) { const gen=[0x3b6a57b2,0x26508e6d,0x1ea119fa,0x3d4233dd,0x2a1462b3]; let chk=1; for(const value of values){const top=chk>>>25;chk=((chk&0x1ffffff)<<5)^value;for(let i=0;i<5;i++)if((top>>>i)&1)chk^=gen[i];} return chk>>>0; }
function bech32m(hrp,data) { const ex=[...hrp].map(c=>c.charCodeAt(0)>>5).concat([0],[...hrp].map(c=>c.charCodeAt(0)&31)); const pm=(polymod(ex.concat(data,[0,0,0,0,0,0]))^BECH32M_CONST)>>>0; const cs=[];for(let i=0;i<6;i++)cs.push((pm>>>(5*(5-i)))&31);return hrp+'1'+data.concat(cs).map(x=>CHARSET[x]).join(''); }
function unified(raw,hrp) { const item=concat(compactSize(TYPE_ORCHARD),compactSize(raw.length),raw), hb=UTF8.encode(hrp), pad=concat(hb,new Uint8Array(16-hb.length)); return bech32m(hrp,convertBits(f4jumble(concat(item,pad)),8,5)); }
export async function deriveWormhole(doc) {
const finalState=await verifyCeremony(doc); const ivk=deriveIvk(finalState), n=bigintFromLE(ivk);
const pkd=encodePoint(pointMul(n,{x:PROFILE_GD_X,y:PROFILE_GD_Y}));
const receiver=concat(PROFILE_D,pkd), rawIvk=concat(PROFILE_DK,ivk);
return {
transcript_hash:bytesToHex(finalState),
raw_receiver_hex:bytesToHex(receiver), raw_incoming_viewing_key_hex:bytesToHex(rawIvk),
unified_address_mainnet:unified(receiver,'u'), unified_address_testnet:unified(receiver,'utest'),
unified_incoming_viewing_key_mainnet:unified(rawIvk,'uivk'), unified_incoming_viewing_key_testnet:unified(rawIvk,'uivktest'),
ivk_hex_le:bytesToHex(ivk), pk_d_hex:bytesToHex(pkd), contribution_count:doc.contributions.length,
};
}
export const __test = {H, f4jumble, unified, deriveIvk, pointMul, encodePoint, PROFILE_GD_X, PROFILE_GD_Y, P, Q};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment