diff --git a/.gitignore b/.gitignore index ea00288..2ed29cb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ lib/nbx-wasm # Build output dist/ examples-dist/ +vendor/ # TypeScript *.tsbuildinfo diff --git a/examples/app.ts b/examples/app.ts index 2603022..1420990 100644 --- a/examples/app.ts +++ b/examples/app.ts @@ -1,4 +1,4 @@ -import { NockchainProvider, wasm } from '../src/index'; +import { NockchainProvider, getLatestTxEngineSettings, wasm } from '../src/index'; const statusDiv = document.getElementById('status')!; const outputPre = document.getElementById('output')!; @@ -9,6 +9,15 @@ const recipientInput = document.getElementById('recipientInput') as HTMLInputEle let provider: NockchainProvider; let grpcEndpoint: string | null = null; let walletPkh: string | null = null; +let txEngineSettings = getLatestTxEngineSettings(); + +function asDigest(value: string): wasm.Digest { + return value as wasm.Digest; +} + +function asNicks(value: string): wasm.Nicks { + return value as wasm.Nicks; +} function log(msg: string) { outputPre.textContent += msg + '\n'; @@ -34,10 +43,11 @@ connectBtn.onclick = async () => { return; } try { - // Connect to wallet (returns pkh and grpcEndpoint) + // Connect to wallet const info = await provider.connect(); - grpcEndpoint = info.grpcEndpoint; - walletPkh = info.pkh; + grpcEndpoint = info.rpcConfig.rpcUrl; + walletPkh = info.account.address; + txEngineSettings = getLatestTxEngineSettings(info.rpcConfig.txEngineActivationHeights); statusDiv.textContent = 'Connected: ' + walletPkh; signRawTxBtn.disabled = false; @@ -67,18 +77,13 @@ signRawTxBtn.onclick = async () => { log('Creating gRPC client for: ' + grpcEndpoint); const grpcClient = new wasm.GrpcClient(grpcEndpoint); - // 3. Create spend condition using wallet PKH (single, no timelock) - log('Creating spend condition for PKH: ' + walletPkh); - const pkh = wasm.Pkh.single(walletPkh); - const spendCondition = wasm.SpendCondition.newPkh(pkh); - - // 4. Get firstName from spend condition - const firstName = spendCondition.firstName(); - log('First name: ' + firstName.value.substring(0, 20) + '...'); - - // 5. Query notes matching this firstName - log('Querying notes from gRPC...'); - const balance = await grpcClient.getBalanceByFirstName(firstName.value); + // 3. Derive first-name from PKH and query notes (notes are indexed by first-name, not address) + const spendCondition: wasm.SpendCondition = [ + { tag: 'pkh', m: 1, hashes: [asDigest(walletPkh)] }, + ]; + const firstName = wasm.spendConditionFirstName(spendCondition); + log('Querying notes by first-name...'); + const balance = await grpcClient.getBalanceByFirstName(firstName); if (!balance || !balance.notes || balance.notes.length === 0) { log('No notes found - wallet might be empty'); @@ -87,76 +92,64 @@ signRawTxBtn.onclick = async () => { log('Found ' + balance.notes.length + ' notes'); - // Convert notes from protobuf - const notes = balance.notes.map((n: any) => wasm.Note.fromProtobuf(n.note)); + // Convert notes from protobuf (0.2: free function) + const notes = balance.notes + .map((entry: any) => entry.note) + .filter(Boolean) + .map((noteProto: wasm.PbCom2Note) => wasm.noteFromProtobuf(noteProto)); + + if (!notes.length) { + log('No parseable notes found'); + return; + } + const note = notes[0]; const noteAssets = note.assets; log('Using note with ' + noteAssets + ' nicks'); - // 6. Build transaction (send 10 NOCK = 655360 nicks) - const TEN_NOCK_IN_NICKS = BigInt(10 * 65536); - const feePerWord = BigInt(32768); // 0.5 NOCK per word + // 4. Build transaction (send 10 NOCK = 655360 nicks) + const TEN_NOCK_IN_NICKS = asNicks(String(10 * 65536)); log('Building transaction to send 10 NOCK...'); - const builder = new wasm.TxBuilder(feePerWord); - - // Create recipient digest - const recipientDigest = new wasm.Digest(recipient); - - // Create refund digest (same as wallet PKH) - const refundDigest = new wasm.Digest(walletPkh); + const builder = new wasm.TxBuilder(txEngineSettings); - // Use simpleSpend (no lockData for lower fees) + // Use simpleSpend (no lockData for lower fees), digest values are strings in 0.2 builder.simpleSpend( - [notes[0]], - [spendCondition], - recipientDigest, + [note], + [spendCondition as unknown as wasm.TxLock], + asDigest(recipient), TEN_NOCK_IN_NICKS, null, // fee_override (let it auto-calculate) - refundDigest, + asDigest(walletPkh), false // include_lock_data ); - // 7. Build the transaction and get notes/spend conditions + // 5. Build the transaction and get notes/spend conditions log('Building raw transaction...'); const nockchainTx = builder.build(); const txId = nockchainTx.id; - log('Transaction ID: ' + txId.value); + log('Transaction ID: ' + txId); - const rawTxProtobuf = nockchainTx.toRawTx().toProtobuf(); - - // Get notes and spend conditions from builder - const txNotes = builder.allNotes(); - - log('Notes count: ' + txNotes.notes.length); - log('Spend conditions count: ' + txNotes.spendConditions.length); - - // 8. Sign using provider.signRawTx (pass wasm objects directly) + // 6. Sign using provider.signTx log('Signing transaction...'); - const signedTxProtobuf = await provider.signRawTx({ - rawTx: rawTxProtobuf, // Pass wasm RawTx directly - notes: txNotes.notes, // Pass wasm Note objects directly - spendConditions: txNotes.spendConditions, // Pass wasm SpendCondition objects directly - }); + const signed = await provider.signTx(nockchainTx); log('Transaction signed successfully!'); - // Convert to jam string for file download - const signedTx = wasm.RawTx.fromProtobuf(signedTxProtobuf); - const jamBytes = signedTx.toJam(); - - // 9. Download to file using transaction ID + // 7. Convert signed tx to Jam and download + const signedRawTx = wasm.nockchainTxToRawTx(signed.tx as any); + const jamBytes = wasm.jam(signedRawTx as unknown as wasm.Noun); const blob = new Blob([new Uint8Array(jamBytes)], { type: 'application/jam' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${txId.value}.tx`; + a.download = `${txId}.tx`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - log('Downloaded transaction to file: ' + txId.value + '.tx'); + log('Downloaded signed transaction (Jam): ' + txId + '.tx'); } catch (e: any) { log('Error: ' + e.message); console.error(e); diff --git a/examples/tx-builder.ts b/examples/tx-builder.ts index 18c3d16..6266b6d 100644 --- a/examples/tx-builder.ts +++ b/examples/tx-builder.ts @@ -1,17 +1,18 @@ -import { NockchainProvider, wasm } from '../src/index'; +import { NockchainProvider, getLatestTxEngineSettings, wasm } from '../src/index'; // ===== Types ===== +// Note: Nicks are strings in current WASM version, so they need to be cast into integers for arithmetic interface Lock { id: string; name: string; - spendConditionProtobuf: Uint8Array; // Store protobuf instead of WASM object + spendCondition: wasm.SpendCondition; // plain array, e.g. [{ Pkh: { m: 1, hashes: [pkh] } }] expanded: boolean; } interface NoteData { note: wasm.Note; - assets: bigint; + assets: wasm.Nicks; firstName: string; lastName: string; } @@ -24,13 +25,13 @@ interface SelectedInput { interface Seed { lockId: string; - amount: bigint; // in nicks + amount: wasm.Nicks; } interface Spend { inputId: string; input: SelectedInput; - fee: bigint; // in nicks + fee: wasm.Nicks; seeds: Seed[]; } @@ -41,6 +42,7 @@ const state = { connected: false, walletPkh: null as string | null, grpcEndpoint: null as string | null, + txEngineSettings: getLatestTxEngineSettings(), provider: null as NockchainProvider | null, grpcClient: null as wasm.GrpcClient | null, @@ -104,18 +106,38 @@ function truncateAddress(addr: string | undefined): string { return `${addr.slice(0, 4)}...${addr.slice(-4)}`; } -function nicksToNock(nicks: bigint): number { - return Number(nicks) / 65536; +function nicksToNock(nicks: wasm.Nicks): number { + return Number(BigInt(nicks)) / 65536; } -function nockToNicks(nock: number): bigint { - return BigInt(Math.floor(nock * 65536)); +function nockToNicks(nock: number): wasm.Nicks { + return asNicks(String(BigInt(Math.floor(nock * 65536)))); } function formatNock(nock: number): string { return nock.toFixed(4); } +function asDigest(value: string): wasm.Digest { + return value as wasm.Digest; +} + +function asNicks(value: string): wasm.Nicks { + return value as wasm.Nicks; +} + +function asBlockHeight(value: number): wasm.BlockHeight { + return value as wasm.BlockHeight; +} + +function asLock(spendCondition: wasm.SpendCondition): wasm.Lock { + return spendCondition as unknown as wasm.Lock; +} + +function asTxLock(spendCondition: wasm.SpendCondition): wasm.TxLock { + return spendCondition as unknown as wasm.TxLock; +} + declare global { interface Window { copyToClipboard: (text: string, element: HTMLElement) => void; @@ -226,8 +248,9 @@ connectBtn.onclick = async () => { try { const info = await state.provider.connect(); - state.grpcEndpoint = info.grpcEndpoint; - state.walletPkh = info.pkh; + state.grpcEndpoint = info.rpcConfig.rpcUrl; + state.walletPkh = info.account.address; + state.txEngineSettings = getLatestTxEngineSettings(info.rpcConfig.txEngineActivationHeights); state.connected = true; connectBtn.textContent = truncateAddress(state.walletPkh); @@ -253,33 +276,40 @@ function createDefaultLocksWithPkh() { const coinbaseDefaultExists = state.locks.some(l => l.id === 'coinbase-default'); if (!pkhDefaultExists) { - // 1. PKH lock - const pkhLock = wasm.Pkh.single(state.walletPkh); - const pkhSpendCondition = wasm.SpendCondition.newPkh(pkhLock); + // Simple 1-of-1 PKH lock + const pkhPrim: wasm.LockPrimitive = { + tag: 'pkh', + m: 1, + hashes: [asDigest(state.walletPkh)], + }; + const spendCondition: wasm.SpendCondition = [pkhPrim]; state.locks.push({ id: 'pkh-default', name: 'Wallet PKH', - spendConditionProtobuf: pkhSpendCondition.toProtobuf(), + spendCondition, expanded: false, }); - pkhSpendCondition.free(); } if (!coinbaseDefaultExists) { - // 2. Coinbase lock (PKH + timelock) - const coinbaseLock = wasm.Pkh.single(state.walletPkh); - const timelockCoinbase = wasm.LockTim.coinbase(); - const coinbaseSpendCondition = new wasm.SpendCondition([ - wasm.LockPrimitive.newPkh(coinbaseLock), - wasm.LockPrimitive.newTim(timelockCoinbase), - ]); + // Coinbase lock (PKH + timelock) + const pkhPrim: wasm.LockPrimitive = { + tag: 'pkh', + m: 1, + hashes: [asDigest(state.walletPkh)], + }; + const coinbaseTim: wasm.LockTim = { + rel: { min: asBlockHeight(100), max: null }, + abs: { min: null, max: null }, + }; + const timPrim: wasm.LockPrimitive = { tag: 'tim', ...coinbaseTim }; + const spendCondition: wasm.SpendCondition = [pkhPrim, timPrim]; state.locks.push({ id: 'coinbase-default', name: 'Wallet Coinbase', - spendConditionProtobuf: coinbaseSpendCondition.toProtobuf(), + spendCondition, expanded: false, }); - coinbaseSpendCondition.free(); } } @@ -327,15 +357,11 @@ async function refreshNotesForLock(lockId: string) { if (!lock || !state.grpcClient) return; try { - // Deserialize spend condition from protobuf to get firstName - const spendCondition = wasm.SpendCondition.fromProtobuf(lock.spendConditionProtobuf); - const firstName = spendCondition.firstName(); - console.log( - `Fetching notes for lock ${lockId}, firstName: ${firstName.value.substring(0, 20)}...` - ); + // 0.2: first name = hash of spend condition (Digest string) + const firstName = wasm.spendConditionHash(lock.spendCondition); + console.log(`Fetching notes for lock ${lockId}, firstName: ${firstName.substring(0, 20)}...`); - const balance = await state.grpcClient.getBalanceByFirstName(firstName.value); - spendCondition.free(); // Clean up + const balance = await state.grpcClient.getBalanceByFirstName(firstName); if (!balance || !balance.notes || balance.notes.length === 0) { state.notes.set(lockId, []); @@ -343,27 +369,21 @@ async function refreshNotesForLock(lockId: string) { return; } - const notes: NoteData[] = ( - balance.notes as Array<{ note: any; firstName?: string; lastName?: string }> - ).map(n => { - const note = wasm.Note.fromProtobuf(n.note); - - // Try to extract name from protobuf if top-level fields are empty - let firstName = n.firstName || ''; - let lastName = n.lastName || ''; - - if (!firstName && n.note?.note_version?.V1?.name) { - firstName = n.note.note_version.V1.name.first || ''; - lastName = n.note.note_version.V1.name.last || ''; - } - - return { - note, - assets: note.assets, - firstName, - lastName, - }; - }); + const notes: NoteData[] = balance.notes + .map((entry: wasm.PbCom2BalanceEntry) => entry.note) + .filter((note): note is wasm.PbCom2Note => note != null) + .map((noteProto: wasm.PbCom2Note) => { + const note = wasm.noteFromProtobuf(noteProto); + const assets = note.assets ?? asNicks('0'); + const firstNameStr = note.name?.first ?? ''; + const lastNameStr = note.name?.last ?? ''; + return { + note, + assets, + firstName: firstNameStr, + lastName: lastNameStr, + }; + }); state.notes.set(lockId, notes); renderNotesForLock(lockId); @@ -434,7 +454,7 @@ function addInputToSpend(lockId: string, noteIndex: number) { const spend: Spend = { inputId: input.id, input, - fee: BigInt(0), + fee: asNicks('0'), seeds: [], }; @@ -507,18 +527,20 @@ function renderSeeds(spend: Spend): string { } function isSpendBalanced(spend: Spend): boolean { - const total = spend.fee + spend.seeds.reduce((sum, seed) => sum + seed.amount, BigInt(0)); - return total === spend.input.note.assets; + const total = + BigInt(spend.fee) + spend.seeds.reduce((sum, seed) => sum + BigInt(seed.amount), BigInt(0)); + return total === BigInt(spend.input.note.assets); } function getSpendBalanceText(spend: Spend): string { - const total = spend.fee + spend.seeds.reduce((sum, seed) => sum + seed.amount, BigInt(0)); - const diff = spend.input.note.assets - total; + const total = + BigInt(spend.fee) + spend.seeds.reduce((sum, seed) => sum + BigInt(seed.amount), BigInt(0)); + const diff = BigInt(spend.input.note.assets) - total; if (diff === BigInt(0)) { - return `✓ Balanced (${formatNock(nicksToNock(total))} NOCK)`; + return `✓ Balanced (${formatNock(nicksToNock(asNicks(String(total))))} NOCK)`; } - const sign = diff > 0 ? '+' : ''; - return `${sign}${formatNock(nicksToNock(diff))} NOCK`; + const sign = diff > 0n ? '+' : ''; + return `${sign}${formatNock(nicksToNock(asNicks(String(diff))))} NOCK`; } function removeSpend(inputId: string) { @@ -544,17 +566,16 @@ function balanceSeed(inputId: string, seedIndex: number) { const seed = spend.seeds[seedIndex]; if (!seed) return; - // Calculate remaining assets: input assets - fee - other seeds - const inputAssets = spend.input.note.assets; - const fee = spend.fee; + const inputAssets = BigInt(spend.input.note.assets); + const fee = BigInt(spend.fee); let otherSeedsTotal = BigInt(0); for (let i = 0; i < spend.seeds.length; i++) { if (i !== seedIndex) { - otherSeedsTotal += spend.seeds[i].amount; + otherSeedsTotal += BigInt(spend.seeds[i].amount); } } - + // Calculate remaining assets const remaining = inputAssets - fee - otherSeedsTotal; if (remaining < 0n) { @@ -562,7 +583,7 @@ function balanceSeed(inputId: string, seedIndex: number) { return; } - seed.amount = remaining; + seed.amount = asNicks(String(remaining)); renderSpends(); updateBuilder(); } @@ -574,9 +595,9 @@ function updateSpendFee(inputId: string, feeStr: string, shouldRender: boolean = // Convert NOCK to nicks const nock = parseFloat(feeStr); if (!isNaN(nock) && nock >= 0) { - spend.fee = BigInt(Math.floor(nock * 65536)); + spend.fee = nockToNicks(nock); } else { - spend.fee = BigInt(0); + spend.fee = asNicks('0'); } if (shouldRender) renderSpends(); updateBuilder(); @@ -591,7 +612,7 @@ function addSeed(inputId: string) { if (spend && state.locks.length > 0) { spend.seeds.push({ lockId: state.locks[0].id, - amount: BigInt(0), + amount: asNicks('0'), }); renderSpends(); updateBuilder(); @@ -619,9 +640,9 @@ function updateSeedAmount( // Convert NOCK to nicks const nock = parseFloat(amountStr); if (!isNaN(nock) && nock >= 0) { - spend.seeds[seedIndex].amount = BigInt(Math.floor(nock * 65536)); + spend.seeds[seedIndex].amount = nockToNicks(nock); } else { - spend.seeds[seedIndex].amount = BigInt(0); + spend.seeds[seedIndex].amount = asNicks('0'); } if (shouldRender) renderSpends(); updateBuilder(); @@ -660,60 +681,44 @@ function updateBuilder() { try { console.log('Building transaction...'); - // Create TxBuilder with default fee-per-word - const feePerWord = BigInt(32768); // 0.5 NOCK per word - const builder = new wasm.TxBuilder(feePerWord); + const builder = new wasm.TxBuilder(state.txEngineSettings); - // Add each spend to the builder using SpendBuilder for (const spend of state.spends) { const lock = state.locks.find(l => l.id === spend.input.lockId); if (!lock) continue; - // Determine refund lock protobuf (use first seed lock if available, otherwise same as input) - const refundLockProtobuf = + const refundLock: wasm.LockRoot | null = spend.seeds.length > 0 - ? state.locks.find(l => l.id === spend.seeds[0].lockId)?.spendConditionProtobuf - : lock.spendConditionProtobuf; - - // Deserialize WASM objects from protobuf (fresh instances every time) - const noteClone = wasm.Note.fromProtobuf(spend.input.note.note.toProtobuf()); - const spendConditionClone = wasm.SpendCondition.fromProtobuf(lock.spendConditionProtobuf); - const refundLockClone = refundLockProtobuf - ? wasm.SpendCondition.fromProtobuf(refundLockProtobuf) - : null; - - // Create SpendBuilder with note, spend condition, and refund lock - const spendBuilder = new wasm.SpendBuilder(noteClone, spendConditionClone, refundLockClone); + ? ((state.locks.find(l => l.id === spend.seeds[0].lockId)?.spendCondition ?? + null) as wasm.SpendCondition | null) + : lock.spendCondition; + + const spendBuilder = new wasm.SpendBuilder( + spend.input.note.note, + asLock(lock.spendCondition), + null, + refundLock ? asLock(refundLock) : null + ); - // Add seeds (outputs) for (const seed of spend.seeds) { const seedLock = state.locks.find(l => l.id === seed.lockId); if (seedLock) { - // Deserialize spend condition from protobuf - const seedSpendCondition = wasm.SpendCondition.fromProtobuf( - seedLock.spendConditionProtobuf - ); - // Create a Seed for this output using the constructor - const seedObj = new wasm.Seed( - null, // output_source - wasm.LockRoot.fromSpendCondition(seedSpendCondition), // lock_root - seed.amount, // gift - wasm.NoteData.empty(), // note_data - spend.input.note.note.hash() // parent_hash - ); - spendBuilder.seed(seedObj); + const seedV1: wasm.SeedV1 = { + output_source: null, + lock_root: asLock(seedLock.spendCondition), + note_data: [], + gift: seed.amount, + parent_hash: wasm.noteHash(spend.input.note.note), + }; + spendBuilder.seed(seedV1); } } - // Set fee if specified - if (spend.fee > 0) { + if (BigInt(spend.fee) > 0n) { spendBuilder.fee(spend.fee); } - // Compute refund to balance out the spend spendBuilder.computeRefund(false); - - // Attach this spend to the main builder builder.spend(spendBuilder); } @@ -942,8 +947,10 @@ function renderTransaction() { } } - const fee = state.builder.curFee(); - const calcFee = state.builder.calcFee(); + const feeStr = state.builder.curFee(); + const calcFeeStr = state.builder.calcFee(); + const fee = BigInt(feeStr); + const calcFee = BigInt(calcFeeStr); const feeSufficient = fee >= calcFee; // If validate() failed, it might be due to fee or missing unlocks. @@ -961,28 +968,26 @@ function renderTransaction() { txInfo.innerHTML = `
- Fee: ${formatNock(nicksToNock(fee))} NOCK + Fee: ${formatNock(nicksToNock(feeStr))} NOCK
- Calculated Fee: ${formatNock(nicksToNock(calcFee))} NOCK + Calculated Fee: ${formatNock(nicksToNock(calcFeeStr))} NOCK
- TX ID: ${renderCopyableId(state.nockchainTx.id.value, 'TX ID')} + TX ID: ${renderCopyableId(state.nockchainTx.id, 'TX ID')}
`; - // Outputs - const outputs = state.nockchainTx.outputs(); + // Preview outputs from the raw transaction using the connected network's tx engine settings. + const rawTx = wasm.nockchainTxToRawTx(state.nockchainTx); + const outputs = wasm.rawTxOutputs(rawTx, 0, state.txEngineSettings); if (outputs && outputs.length > 0) { outputsList.innerHTML = outputs .map((output: wasm.Note, index: number) => { - const amount = output.assets || BigInt(0); - - // Extract name directly from WASM object - const firstName = output.name?.first || ''; - const lastName = output.name?.last || ''; - + const amount = output.assets ?? asNicks('0'); + const firstName = output.name?.first ?? ''; + const lastName = output.name?.last ?? ''; return `
@@ -1419,109 +1424,66 @@ function confirmAddLock() { } try { - // Build lock primitives - const primitives: wasm.LockPrimitive[] = []; + const primitives: wasm.SpendCondition = []; for (const prim of modalPrimitives) { switch (prim.type) { - case 'pkh': + case 'pkh': { const pkhConfig = prim.pkh || { m: 1, addrs: [] }; const validAddrs = pkhConfig.addrs.filter(a => a.trim() !== ''); - if (validAddrs.length === 0) { alert('PKH requires at least one address'); return; } - - // For now, if m=1 and 1 addr, use single. - // If m>1 or multiple addrs, we need multisig support in WASM or manual construction - // Assuming WASM has Pkh.new(m, addrs) or similar. - // Checking iris_wasm.d.ts... - // It seems Pkh.single(addr) is what we used. - // Let's assume for now we only support single if m=1 and len=1. - // Actually, let's try to find if there is a multisig constructor. - // If not, we might be limited. But user asked for it. - // Let's assume Pkh constructor takes (m, addrs) or similar if we look at the types. - // Based on previous knowledge, Pkh might be complex. - // Let's try to use Pkh.new(threshold, keys) if it exists, otherwise fallback/error. - - // I will assume for this step that I can create a Pkh object. - // If I look at how `wasm.Pkh.single` works, it probably creates a Pkh with threshold 1. - - // Let's try: - // const pkh = new wasm.Pkh(pkhConfig.m, pkhConfig.addrs); - // If that fails, I'll see in build. - // Actually, looking at previous code: `wasm.Pkh.single(state.walletPkh)` - - if (validAddrs.length === 1 && pkhConfig.m === 1) { - const pkh = wasm.Pkh.single(validAddrs[0]); - primitives.push(wasm.LockPrimitive.newPkh(pkh)); - } else { - try { - const pkh = new wasm.Pkh(BigInt(pkhConfig.m), validAddrs); - primitives.push(wasm.LockPrimitive.newPkh(pkh)); - } catch (e) { - console.error('Failed to create multisig PKH', e); - alert('Failed to create multisig PKH: ' + (e as Error).message); - return; - } - } + primitives.push({ + tag: 'pkh', + m: pkhConfig.m, + hashes: validAddrs.map(addr => asDigest(addr)), + }); break; - - case 'tim': + } + case 'tim': { const timConfig = prim.tim || { type: 'csv', value: 1 }; - let tim; + const value = asBlockHeight(timConfig.value ?? 0); if (timConfig.type === 'csv') { - // Relative timelock (CSV) - // min=value, max=null for relative part - const rel = new wasm.TimelockRange(BigInt(timConfig.value), null); - const abs = new wasm.TimelockRange(null, null); - tim = new wasm.LockTim(rel, abs); + primitives.push({ + tag: 'tim', + rel: { min: value, max: null }, + abs: { min: null, max: null }, + }); } else { - // Absolute timelock (CLTV) - // min=value, max=null for absolute part - const rel = new wasm.TimelockRange(null, null); - const abs = new wasm.TimelockRange(BigInt(timConfig.value), null); - tim = new wasm.LockTim(rel, abs); + primitives.push({ + tag: 'tim', + rel: { min: null, max: null }, + abs: { min: value, max: null }, + }); } - primitives.push(wasm.LockPrimitive.newTim(tim)); break; - - case 'hax': + } + case 'hax': { const haxConfig = prim.hax || { hashes: [] }; const validHashes = haxConfig.hashes.filter(h => h.trim() !== ''); - if (validHashes.length === 0) { - // Allow empty for "any preimage"? User prompt said "leave blank to require any preimage" - // But HAX usually requires at least one hash unless it's a specific "any" type? - // If hashes is empty, maybe we don't add it? Or add empty Hax? - // `new wasm.Hax([])` might work. alert('HAX requires at least one hash.'); return; } - - const digests = validHashes.map(h => new wasm.Digest(h)); - const hax = new wasm.Hax(digests); - primitives.push(wasm.LockPrimitive.newHax(hax)); + primitives.push({ tag: 'hax', preimages: validHashes.map(hash => asDigest(hash)) }); break; - + } case 'brn': - primitives.push(wasm.LockPrimitive.newBrn()); + primitives.push({ tag: 'brn' }); break; } } - const spendCondition = new wasm.SpendCondition(primitives); - const newLock: Lock = { id: generateId(), name, - spendConditionProtobuf: spendCondition.toProtobuf(), + spendCondition: primitives, expanded: false, }; state.locks.push(newLock); - spendCondition.free(); // Clean up WASM object renderLocks(); closeAddLockModal(); } catch (e) { @@ -1537,7 +1499,7 @@ function exportLocks() { const locksData = state.locks.map(lock => ({ id: lock.id, name: lock.name, - spendCondition: lock.spendConditionProtobuf, // Already protobuf + spendCondition: lock.spendCondition, // 0.2: plain array })); const json = JSON.stringify(locksData, null, 2); @@ -1578,32 +1540,23 @@ function importLocks() { let importedCount = 0; for (const lockData of locksData) { - // Validate it's indeed a spend condition by deserializing - const spendCondition = wasm.SpendCondition.fromProtobuf(lockData.spendCondition); - const hash = spendCondition.hash().value; - - // Check for duplicates by hash - const exists = state.locks.some(l => { - const existingSc = wasm.SpendCondition.fromProtobuf(l.spendConditionProtobuf); - const existingHash = existingSc.hash().value; - existingSc.free(); - return existingHash === hash; - }); - + const sc = lockData.spendCondition; + if (!Array.isArray(sc) || sc.length === 0) { + console.log('Skipping invalid lock (expected spendCondition array):', lockData.name); + continue; + } + const hash = wasm.spendConditionHash(sc as wasm.SpendCondition); + const exists = state.locks.some(l => wasm.spendConditionHash(l.spendCondition) === hash); if (exists) { console.log(`Skipping duplicate lock: ${lockData.name} (${hash})`); - spendCondition.free(); continue; } - - const lock: Lock = { + state.locks.push({ id: lockData.id || generateId(), name: lockData.name || 'Imported Lock', - spendConditionProtobuf: lockData.spendCondition, // Store as protobuf + spendCondition: sc as wasm.SpendCondition, expanded: false, - }; - state.locks.push(lock); - spendCondition.free(); + }); importedCount++; } @@ -1640,8 +1593,9 @@ downloadTxBtn.onclick = () => { if (!state.nockchainTx) return; try { - const jamBytes = state.nockchainTx.toJam(); - const txId = state.nockchainTx.id.value; + const rawTx = wasm.nockchainTxToRawTx(state.nockchainTx); + const jamBytes = wasm.jam(rawTx as unknown as wasm.Noun); + const txId = state.nockchainTx.id; const blob = new Blob([new Uint8Array(jamBytes)], { type: 'application/jam' }); const url = URL.createObjectURL(blob); @@ -1664,32 +1618,15 @@ signTxBtn.onclick = async () => { if (!state.nockchainTx || !state.builder || !state.provider) return; try { - // Get all notes and spend conditions from builder - const txNotes = state.builder.allNotes(); - - // Sign using provider - const signedTxProtobuf = await state.provider.signRawTx({ - rawTx: state.nockchainTx.toRawTx(), - notes: txNotes.notes, - spendConditions: txNotes.spendConditions, - }); - - // Store signed TX - // NockchainTx doesn't have fromProtobuf, so we go via RawTx - const signedRawTx = wasm.RawTx.fromProtobuf(signedTxProtobuf); - state.signedTx = signedRawTx.toNockchainTx(); + const signed = await state.provider.signTx(state.nockchainTx); + const signedRawTx = wasm.nockchainTxToRawTx(signed.tx) as wasm.RawTxV1; + state.signedTx = signed.tx; - // Validate the signed transaction - console.log('Validating signed transaction...'); let isValid = true; let validationError = ''; try { - const signedBuilder = wasm.TxBuilder.fromTx( - state.signedTx.toRawTx(), - txNotes.notes, - txNotes.spendConditions - ); + const signedBuilder = wasm.TxBuilder.fromNockchainTx(signed.tx, state.txEngineSettings); signedBuilder.validate(); signedBuilder.free(); console.log('Signed transaction validated successfully'); @@ -1698,15 +1635,13 @@ signTxBtn.onclick = async () => { isValid = false; validationError = e instanceof Error ? e.message : String(e); } - - console.log(state.signedTx.toRawTx().toProtobuf()); if (state.signedTx) { - state.signedTxId = state.signedTx.id.value; - // Show signed TX section + state.signedTxId = state.signedTx.id; signedTxSection.classList.remove('hidden'); - - // Render TX ID - signedTxIdEl.innerHTML = renderCopyableId(state.signedTxId, 'Signed TX ID'); + signedTxIdEl.innerHTML = renderCopyableId( + state.signedTxId ?? state.signedTx.id, + 'Signed TX ID' + ); // Render Validation Status const signedTxValidation = document.getElementById('signedTxValidation'); @@ -1738,7 +1673,8 @@ document.getElementById('downloadSignedTxBtn')!.onclick = () => { if (!state.signedTx || !state.signedTxId) return; try { - const jamBytes = state.signedTx.toJam(); + const rawTx = wasm.nockchainTxToRawTx(state.signedTx); + const jamBytes = wasm.jam(rawTx as unknown as wasm.Noun); const blob = new Blob([new Uint8Array(jamBytes)], { type: 'application/jam' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -1749,7 +1685,8 @@ document.getElementById('downloadSignedTxBtn')!.onclick = () => { document.body.removeChild(a); URL.revokeObjectURL(url); - const rawTxBytes = state.signedTx.toRawTx().toJam(); + const rawTxForJam = wasm.nockchainTxToRawTx(state.signedTx); + const rawTxBytes = wasm.jam(rawTxForJam as unknown as wasm.Noun); const blobRaw = new Blob([new Uint8Array(rawTxBytes)], { type: 'application/jam' }); const urlRaw = URL.createObjectURL(blobRaw); const aRaw = document.createElement('a'); @@ -1771,8 +1708,8 @@ document.getElementById('submitSignedTxBtn')!.onclick = async () => { try { if (!state.grpcClient) throw new Error('gRPC client not initialized'); - // Convert to protobuf for sending - const txProtobuf = state.signedTx.toRawTx().toProtobuf(); + const rawTx = wasm.nockchainTxToRawTx(state.signedTx) as wasm.RawTxV1; + const txProtobuf = wasm.rawTxToProtobuf(rawTx); await state.grpcClient.sendTransaction(txProtobuf); console.log('Transaction submitted successfully!'); diff --git a/package-lock.json b/package-lock.json index cd670e6..86482b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@nockbox/iris-sdk", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nockbox/iris-sdk", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { - "@nockbox/iris-wasm": "^0.1.1", + "@nockbox/iris-wasm": "0.2.0", "@scure/base": "^2.0.0" }, "devDependencies": { @@ -411,15 +411,15 @@ } }, "node_modules/@nockbox/iris-wasm": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@nockbox/iris-wasm/-/iris-wasm-0.1.1.tgz", - "integrity": "sha512-uLtRNl6serdk8Ysu5BN38NCs2vWWKsvvfTmT54CFtk4ebOsjIOcg1ViBeeUVpikToTgTlijtMYYEpjs2KIBLHg==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@nockbox/iris-wasm/-/iris-wasm-0.2.0.tgz", + "integrity": "sha512-FUtYQ8nakzjBXuWKhgw8/QvEzzHfpJkLkkzdv0xu4t6Nn0hNDTkO8SC/Av5O8mTHXsFNTRNva1i4Dab6r29lLA==", "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -431,9 +431,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -445,9 +445,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -459,9 +459,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -473,9 +473,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -487,9 +487,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -501,9 +501,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -515,9 +515,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -529,9 +529,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], @@ -543,9 +543,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -557,9 +557,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -571,9 +585,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -585,9 +613,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -599,9 +627,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -613,9 +641,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -627,9 +655,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], @@ -641,9 +669,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -654,10 +682,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -669,9 +711,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -683,9 +725,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -697,9 +739,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -711,9 +753,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -725,9 +767,9 @@ ] }, "node_modules/@scure/base": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", - "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" @@ -741,9 +783,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", - "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "dev": true, "license": "MIT", "dependencies": { @@ -805,9 +847,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -831,9 +873,9 @@ "license": "ISC" }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -851,7 +893,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -860,9 +902,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -876,9 +918,9 @@ } }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -892,28 +934,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, diff --git a/package.json b/package.json index 40910ed..65e20c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nockbox/iris-sdk", - "version": "0.1.1", + "version": "0.2.0", "description": "TypeScript SDK for interacting with Iris wallet extension", "type": "module", "main": "./dist/index.js", @@ -9,6 +9,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./wasm": { + "types": "./dist/wasm.d.ts", + "import": "./dist/wasm.js" } }, "files": [ @@ -16,8 +20,8 @@ "README.md" ], "scripts": { - "prepare": "npm run build", "build": "tsc", + "prepare": "npm run build", "build-example": "npm run build && tsc --project examples/tsconfig.json && vite build --config vite.config.examples.ts", "dev": "tsc --watch", "dev-example": "vite --config vite.config.examples.ts", @@ -37,16 +41,16 @@ "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", + "prettier": "^3.4.2", "typescript": "^5.5.3", - "vite": "^5.0.0", - "prettier": "^3.4.2" + "vite": "^5.0.0" }, "repository": { "type": "git", "url": "https://github.com/nockbox/iris-sdk.git" }, "dependencies": { - "@scure/base": "^2.0.0", - "@nockbox/iris-wasm": "^0.1.1" + "@nockbox/iris-wasm": "0.2.0", + "@scure/base": "^2.0.0" } } diff --git a/src/bridge-types.ts b/src/bridge-types.ts new file mode 100644 index 0000000..277c881 --- /dev/null +++ b/src/bridge-types.ts @@ -0,0 +1,99 @@ +/** + * Bridge configuration and transaction types for the SDK. + * Consumers (nockswap, extension) provide BridgeConfig; the SDK handles tx construction and validation. + */ + +import type { + NockchainTx, + Nicks, + Note, + SpendCondition, + TxEngineSettings, +} from '@nockbox/iris-wasm/iris_wasm.js'; + +export type { Nicks, TxEngineSettings }; + +/** + * Configuration for a specific bridge (e.g. Zorp Nock→Base). + * Pass this to all bridge functions so the SDK can build/validate transactions without hardcoded constants. + */ +export interface BridgeConfig { + /** Multisig threshold (e.g. 3 for 3-of-5) */ + threshold: number; + /** Multisig PKH addresses (Nockchain addresses) */ + addresses: string[]; + /** Note data key used for bridge payload (e.g. "bridge") */ + noteDataKey: string; + /** Chain identifier in note data, hex string (e.g. "65736162" for %base) */ + chainTag: string; + /** Version tag in note data (e.g. "0") */ + versionTag: string; + /** Minimum amount in nicks for a valid bridge output (for validation) */ + minAmountNicks: Nicks; + /** Optional: expected lock root hash for bridge output (if set, validation checks it) */ + expectedLockRoot?: string; +} + +/** + * Parameters for building a bridge transaction. + * Input notes and spend conditions are supplied by the consumer (e.g. from gRPC). + */ +export interface BridgeTransactionParams { + /** User's input notes (UTXOs to spend) */ + inputNotes: Note[]; + /** Spend conditions for each input note */ + spendConditions: SpendCondition[]; + /** Amount to bridge in nicks (WASM Nicks = string) */ + amountInNicks: Nicks; + /** Destination EVM address on the target chain */ + destinationAddress: string; + /** User's PKH for refunds/change */ + refundPkh: string; +} + +/** + * Options for bridge transaction build / validation (mirrors migration: caller supplies tx engine). + */ +export interface BuildBridgeTransactionOptions { + txEngineSettings: TxEngineSettings; +} + +/** Intent a built bridge transaction must match (same fields used to construct it). */ +export type BridgeValidationParams = Pick< + BridgeTransactionParams, + 'destinationAddress' | 'amountInNicks' | 'refundPkh' +>; + +/** + * Result of building a bridge transaction (unsigned). + */ +export interface BridgeTransactionResult { + /** The built transaction (ready for signing) */ + transaction: NockchainTx; + /** Transaction ID */ + txId: string; + /** Calculated fee in nicks (WASM Nicks = string) */ + fee: Nicks; +} + +/** + * Result of validating a bridge transaction (pre- or post-signing). + */ +export interface BridgeValidationResult { + valid: boolean; + error?: string; + /** Amount being sent to bridge in nicks */ + bridgeAmountNicks?: Nicks; + /** Destination EVM address extracted from note data */ + destinationAddress?: string; + /** Belt encoding extracted from note data */ + belts?: [bigint, bigint, bigint]; + /** Note data key */ + noteDataKey?: string; + /** Bridge version */ + version?: string; + /** Chain identifier */ + chain?: string; + /** Lock root digest for the bridge output seed */ + bridgeLockRoot?: string; +} diff --git a/src/bridge.ts b/src/bridge.ts new file mode 100644 index 0000000..4406b32 --- /dev/null +++ b/src/bridge.ts @@ -0,0 +1,604 @@ +/** + * Bridge utilities for Nockchain ↔ EVM bridging. + * Encoding uses the wasm atom/belt conversion helpers for EVM addresses. + * Consumers provide BridgeConfig; the SDK handles transaction construction and validation. + */ + +import type { + BridgeConfig, + BridgeTransactionParams, + BridgeTransactionResult, + BridgeValidationParams, + BridgeValidationResult, + BuildBridgeTransactionOptions, +} from './bridge-types.js'; +import type { + Lock, + LockRoot, + Digest, + NoteData, + NoteV1, + Nicks, + Noun, + RawTxV1, + SeedV1, + SpendCondition, +} from '@nockbox/iris-wasm/iris_wasm.js'; +import { base58 } from '@scure/base'; +import * as wasm from './wasm.js'; + +/** Simple EVM address check (0x + 40 hex chars). No checksum validation. */ +export function isEvmAddress(address: string): boolean { + const s = (address || '').trim(); + const normalized = s.startsWith('0x') ? s : `0x${s}`; + return /^0x[0-9a-fA-F]{40}$/.test(normalized); +} + +function evmAddressToAtom(address: string): string { + return normalizeEvmAddress(address).slice(2).replace(/^0+/, '') || '0'; +} + +function atomToEvmAddress(atom: string): string { + return `0x${atom.padStart(40, '0')}`; +} + +function atomToBigint(atom: string): bigint { + return BigInt(`0x${atom}`); +} + +function nounArray(head: Noun, ...tail: Noun[]): Noun { + const noun: [Noun] = [head]; + noun.push(...tail); + return noun; +} + +function beltTupleToNoun(belt1: bigint, belt2: bigint, belt3: bigint): Noun { + return nounArray(bigintToAtom(belt1), bigintToAtom(belt2), bigintToAtom(belt3)); +} + +function beltNounToTuple(noun: Noun): [bigint, bigint, bigint] { + const atoms = Array.isArray(noun) ? Array.from(noun) : null; + if (!Array.isArray(atoms) || atoms.length > 3 || !atoms.every(isAtom)) { + throw new Error('Invalid EVM address belt encoding: expected at most 3 belt atoms'); + } + + return [ + atomToBigint(atoms[0] ?? '0'), + atomToBigint(atoms[1] ?? '0'), + atomToBigint(atoms[2] ?? '0'), + ]; +} + +/** + * Convert an EVM address to 3 belts through the canonical atom encoding. + */ +export function evmAddressToBelts(address: string): [bigint, bigint, bigint] { + if (!isEvmAddress(address)) { + throw new Error(`Invalid EVM address: ${address}`); + } + + return beltNounToTuple(wasm.atomToBelts(evmAddressToAtom(address))); +} + +/** + * Convert 3 belts back to an EVM address. + */ +export function beltsToEvmAddress(belt1: bigint, belt2: bigint, belt3: bigint): string { + const atom = wasm.beltsToAtom(beltTupleToNoun(belt1, belt2, belt3)); + if (!isAtom(atom)) { + throw new Error('Invalid EVM address belt encoding: decoded atom is not an atom'); + } + return atomToEvmAddress(atom); +} + +/** Encode a bigint as hex (no 0x prefix). */ +export function bigintToAtom(n: bigint): string { + if (n === 0n) return '0'; + return n.toString(16); +} + +/** + * Build the bridge noun structure for an EVM address. + * + * Shape: `[version [chain [belt1 [belt2 belt3]]]]` — a chain of right-nested + * pairs with five hex-encoded atom leaves, matching the Hoon cell stored in + * the on-chain bridge note. + */ +export function buildBridgeNoun( + evmAddress: string, + config: Pick +): Noun { + const [belt1, belt2, belt3] = evmAddressToBelts(evmAddress); + return nounArray( + config.versionTag, + nounArray( + config.chainTag, + nounArray(bigintToAtom(belt1), nounArray(bigintToAtom(belt2), bigintToAtom(belt3))) + ) + ); +} + +/** + * Verify belt encoding round-trips correctly. + */ +export function verifyBeltEncoding(address: string): boolean { + if (!isEvmAddress(address)) return false; + const normalized = address.toLowerCase().startsWith('0x') + ? address.toLowerCase() + : `0x${address.toLowerCase()}`; + const [belt1, belt2, belt3] = evmAddressToBelts(normalized); + const recovered = beltsToEvmAddress(belt1, belt2, belt3); + return normalized === recovered; +} + +/** + * Check if a bridge config is valid and usable. + */ +export function isBridgeConfigured(config: BridgeConfig): boolean { + return ( + config.addresses.length > 0 && + config.threshold > 0 && + config.threshold <= config.addresses.length + ); +} + +// TODO(iris-wasm): `isAtom` and `readPair` are adapters for `Noun`'s runtime +// shape, not bridge logic. They should live alongside `cue`/`jam` in +// `iris-wasm` (ideally replaced by a proper `nounToJs` that returns nested +// pairs). Keep them here until that package exposes an equivalent. + +/** + * Type guard: is this noun an atom (a leaf, represented as a hex string)? + * + * After this returns `true` TypeScript narrows the noun to `string`, so + * callers can use the value directly without a separate rebinding. + */ +function isAtom(noun: Noun | undefined): noun is string { + return typeof noun === 'string'; +} + +/** + * Read one `[head, tail]` pair out of a noun. + * + * A noun is either an atom (string) or a pair of nouns. On-chain the bridge + * note is stored as a chain of right-nested pairs: `[v [c [b1 [b2 b3]]]]`. + * + * The wasm we use here returns that nested structure to JS in a *flattened* + * form: instead of `["v", ["c", ["b1", ["b2", "b3"]]]]` we receive + * `["v", "c", "b1", "b2", "b3"]`. Same data, same pairing intent, just + * collapsed by the serializer on the way across the JS boundary. + * + * To read it back as logical pairs we treat the flat array as + * `[head, ...tail]`: the first element is this pair's head, and everything + * after it is the tail (itself another noun). This function applies that + * convention once; call it repeatedly to walk the chain. + * + * Returns `null` if the noun is not a pair (e.g. an atom, or malformed). + */ +function readPair(noun: Noun | undefined): [Noun, Noun] | null { + // The wasm `Noun` type is declared as `string | [Noun]`, but at runtime a + // pair arrives as a flat array of length >= 2. Copy it into a normal array + // so we can inspect the real shape. + const arr = Array.isArray(noun) ? Array.from(noun) : null; + if (!arr || arr.length < 2) return null; + const head = arr[0]; + const tail = arr.length === 2 ? arr[1] : nounArray(arr[1], ...arr.slice(2)); + return [head, tail]; +} + +function parseDigestString(value: string, field: string): Digest { + const trimmed = value.trim(); + const bytes = base58.decode(trimmed); + if (bytes.length !== 40) { + throw new Error(`Invalid ${field}: expected a 40-byte base58 digest`); + } + return trimmed as Digest; +} + +function normalizeEvmAddress(address: string): string { + const trimmed = address.trim(); + const withPrefix = trimmed.startsWith('0x') ? trimmed : `0x${trimmed}`; + return withPrefix.toLowerCase(); +} + +function noteDataHasKey(noteData: NoteData | undefined, key: string): boolean { + if (!Array.isArray(noteData)) return false; + return noteData.some(entry => Array.isArray(entry) && entry[0] === key); +} + +function digestFromLockRoot(lockRoot: LockRoot): string { + if (typeof lockRoot === 'string') { + return lockRoot; + } + return wasm.lockRootHash(lockRoot); +} + +function collectBridgeSeeds(rawTx: RawTxV1, noteDataKey: string): SeedV1[] { + const bridgeSeeds: SeedV1[] = []; + for (const [, spend] of rawTx.spends) { + for (const seed of spend.seeds) { + if (noteDataHasKey(seed.note_data, noteDataKey)) { + bridgeSeeds.push(seed); + } + } + } + return bridgeSeeds; +} + +/** Read V1 output note data*/ +function bridgeOutputData( + note: NoteV1, + noteDataKey: string +): { assets: bigint; noteData: Noun } | null { + const entry = note.note_data.find( + (row): row is [string, Noun] => Array.isArray(row) && row[0] === noteDataKey + ); + if (!entry) return null; + return { + assets: BigInt(note.assets ?? 0), + noteData: entry[1], + }; +} + +/** + * Derive the bridge multisig lock root from config (same path as buildBridgeTransaction). + */ +export function computeBridgeLockRoot(config: BridgeConfig): string { + const bridgePkh = wasm.pkhNew( + BigInt(config.threshold), + config.addresses.map(address => parseDigestString(address, 'bridge address')) + ); + const bridgeSpendCondition = wasm.spendConditionNewPkh(bridgePkh); + return wasm.lockHash(bridgeSpendCondition); +} + +function computeRefundLockRoot(refundPkh: string): string { + const refundPkhObj = wasm.pkhSingle(parseDigestString(refundPkh, 'refund pkh')); + const refundSpendCondition = wasm.spendConditionNewPkh(refundPkhObj); + return wasm.lockHash(refundSpendCondition); +} + +/** + * Create jammed bridge note data for an EVM address (requires WASM). + * Caller must have initialized WASM (e.g. await wasm.default()) before using. + */ +export async function createBridgeNoteData( + evmAddress: string, + config: BridgeConfig +): Promise { + const nounJs = buildBridgeNoun(evmAddress, config); + return wasm.jam(nounJs); +} + +/** + * Build a bridge transaction (requires WASM). + * Consumer supplies notes and spend conditions; SDK builds the tx from config. + */ +export async function buildBridgeTransaction( + params: BridgeTransactionParams, + config: BridgeConfig, + options: BuildBridgeTransactionOptions +): Promise { + if (!isBridgeConfigured(config)) { + throw new Error('Bridge not configured'); + } + if (!options?.txEngineSettings) { + throw new Error('txEngineSettings is required in options (see BuildBridgeTransactionOptions)'); + } + if (!isEvmAddress(params.destinationAddress)) { + throw new Error(`Invalid destination address: ${params.destinationAddress}`); + } + if (params.inputNotes.length !== params.spendConditions.length) { + throw new Error( + `Input note/spend condition length mismatch: ${params.inputNotes.length} notes vs ${params.spendConditions.length} conditions` + ); + } + + const bridgeNounJs = buildBridgeNoun(params.destinationAddress, config); + const noteData: NoteData = [[config.noteDataKey, bridgeNounJs]]; + + const bridgePkh = wasm.pkhNew( + BigInt(config.threshold), + config.addresses.map(address => parseDigestString(address, 'bridge address')) + ); + const bridgeSpendCondition: SpendCondition = wasm.spendConditionNewPkh(bridgePkh); + const bridgeLockRoot: LockRoot = bridgeSpendCondition; + const refundPkhObj = wasm.pkhSingle(parseDigestString(params.refundPkh, 'refund pkh')); + const refundSpendCondition: SpendCondition = wasm.spendConditionNewPkh(refundPkhObj); + const refundLockRoot: LockRoot = refundSpendCondition; + + const builder = new wasm.TxBuilder(options.txEngineSettings); + + try { + let remainingGift = BigInt(params.amountInNicks); + + for (let i = 0; i < params.inputNotes.length; i++) { + const note = params.inputNotes[i]; + const spendCondition = params.spendConditions[i]; + if (!spendCondition) { + throw new Error('Spend condition is missing for this input note'); + } + const noteAssets = BigInt(note.assets ?? 0); + + const giftPortion = remainingGift < noteAssets ? remainingGift : noteAssets; + remainingGift -= giftPortion; + + let spendBuilder: InstanceType | undefined; + try { + spendBuilder = new wasm.SpendBuilder(note, spendCondition, 0, refundLockRoot); + + if (giftPortion > 0n) { + const parentHash = wasm.noteHash(note); + const seed: SeedV1 = { + output_source: null, + lock_root: bridgeLockRoot, + note_data: noteData, + gift: giftPortion.toString() as Nicks, + parent_hash: parentHash, + }; + spendBuilder.seed(seed); + } + + spendBuilder.computeRefund(false); + builder.spend(spendBuilder); + spendBuilder = undefined; + } finally { + spendBuilder?.free(); + } + } + + if (remainingGift > 0n) { + const requestedGift = BigInt(params.amountInNicks); + const fundedGift = requestedGift - remainingGift; + throw new Error( + `Insufficient input note assets for bridge amount: requested ${requestedGift.toString()} nicks, funded ${fundedGift.toString()} nicks, shortfall ${remainingGift.toString()} nicks` + ); + } + + builder.recalcAndSetFee(false); + const feeResult = builder.curFee(); + const transaction = builder.build(); + + const txId = transaction.id; + const fee = feeResult; + + return { + transaction, + txId, + fee, + }; + } finally { + builder.free(); + } +} + +/** + * Validate a bridge transaction (pre- or post-signing). + * Uses config for note key, min amount, and optional lock root. + */ +export async function validateBridgeTransaction( + rawTx: RawTxV1, + params: BridgeValidationParams, + config: BridgeConfig, + options: BuildBridgeTransactionOptions +): Promise { + if (!options?.txEngineSettings) { + throw new Error('txEngineSettings is required in options (see BuildBridgeTransactionOptions)'); + } + if (!isEvmAddress(params.destinationAddress)) { + return { valid: false, error: `Invalid destination address: ${params.destinationAddress}` }; + } + try { + const bridgeLockRoot = computeBridgeLockRoot(config); + if (config.expectedLockRoot && config.expectedLockRoot !== bridgeLockRoot) { + return { + valid: false, + error: 'Bridge configuration lock root does not match configured bridge signer set', + }; + } + const refundLockRoot = computeRefundLockRoot(params.refundPkh); + + const bridgeSeeds = collectBridgeSeeds(rawTx, config.noteDataKey); + if (bridgeSeeds.length === 0) { + return { + valid: false, + error: `No bridge seed with '${config.noteDataKey}' note data found in transaction spends`, + }; + } + + let bridgeSeedGiftTotal = 0n; + for (const [, spend] of rawTx.spends) { + for (const seed of spend.seeds) { + const seedLockRoot = digestFromLockRoot(seed.lock_root); + if (noteDataHasKey(seed.note_data, config.noteDataKey)) { + if (seedLockRoot !== bridgeLockRoot) { + return { + valid: false, + error: `Bridge seed lock root mismatch: expected ${bridgeLockRoot}, got ${seedLockRoot}`, + }; + } + bridgeSeedGiftTotal += BigInt(seed.gift ?? 0); + } else if (seedLockRoot !== refundLockRoot) { + return { + valid: false, + error: `Refund seed lock root mismatch: expected ${refundLockRoot}, got ${seedLockRoot}`, + }; + } + } + } + + const outputs = wasm.rawTxV1Outputs(rawTx, 0, options.txEngineSettings); + + if (outputs.length === 0) { + return { valid: false, error: 'Transaction has no outputs' }; + } + + let bridgeOutput: ReturnType = null; + for (const output of outputs) { + bridgeOutput = bridgeOutputData(output, config.noteDataKey); + if (bridgeOutput) break; + } + + if (!bridgeOutput) { + return { + valid: false, + error: `No output with '${config.noteDataKey}' note data found in transaction`, + }; + } + + if (BigInt(bridgeOutput.assets) < BigInt(config.minAmountNicks)) { + return { + valid: false, + error: `Bridge amount ${bridgeOutput.assets} nicks is below minimum ${config.minAmountNicks} nicks`, + }; + } + + if (bridgeSeedGiftTotal !== BigInt(bridgeOutput.assets)) { + return { + valid: false, + error: `Bridge seed gifts (${bridgeSeedGiftTotal} nicks) do not match bridge output (${bridgeOutput.assets} nicks)`, + }; + } + + const expectedAmount = BigInt(params.amountInNicks); + if (BigInt(bridgeOutput.assets) !== expectedAmount) { + return { + valid: false, + error: `Bridge amount mismatch: expected ${expectedAmount} nicks, got ${bridgeOutput.assets} nicks`, + }; + } + + let destinationAddress: string | undefined; + let belts: [bigint, bigint, bigint] | undefined; + let validatedVersion: string | undefined; + let validatedChain: string | undefined; + const validatedNoteDataKey = config.noteDataKey; + + try { + // Walk the native note-data noun as a chain of right-nested pairs: + // [version [chain [belt1 [belt2 belt3]]]]. + const noun = bridgeOutput.noteData; + + const versionPair = readPair(noun); + if (!versionPair) { + return { + valid: false, + error: 'Invalid bridge note data structure: expected [version, [chain, belts]]', + }; + } + const [version, chainAndBelts] = versionPair; + if (!isAtom(version)) { + return { valid: false, error: 'Invalid bridge note data: version is not an atom' }; + } + if (version !== config.versionTag && version !== String(Number(config.versionTag))) { + return { + valid: false, + error: `Invalid bridge note data version: expected ${config.versionTag}, got ${version}`, + }; + } + validatedVersion = version; + + const chainPair = readPair(chainAndBelts); + if (!chainPair) { + return { valid: false, error: 'Invalid bridge note data: missing chain and belts' }; + } + const [chain, beltData] = chainPair; + if (!isAtom(chain) || chain !== config.chainTag) { + return { + valid: false, + error: `Invalid bridge chain: expected ${config.chainTag}, got ${String(chain)}`, + }; + } + validatedChain = chain; + + const belt1Pair = readPair(beltData); + if (!belt1Pair) { + return { valid: false, error: 'Invalid bridge note data: invalid belt structure' }; + } + const [belt1Noun, belt2And3] = belt1Pair; + + const belt2Pair = readPair(belt2And3); + if (!belt2Pair) { + return { valid: false, error: 'Invalid bridge note data: invalid belt2/belt3 structure' }; + } + const [belt2Noun, belt3Noun] = belt2Pair; + + if (!isAtom(belt1Noun) || !isAtom(belt2Noun) || !isAtom(belt3Noun)) { + return { valid: false, error: 'Invalid bridge note data: belt values are not atoms' }; + } + + const belt1 = BigInt('0x' + belt1Noun); + const belt2 = BigInt('0x' + belt2Noun); + const belt3 = BigInt('0x' + belt3Noun); + belts = [belt1, belt2, belt3]; + destinationAddress = beltsToEvmAddress(belt1, belt2, belt3); + + if (!isEvmAddress(destinationAddress)) { + return { + valid: false, + error: `Reconstructed address is invalid: ${destinationAddress}`, + }; + } + + const expectedDestination = normalizeEvmAddress(params.destinationAddress); + const actualDestination = normalizeEvmAddress(destinationAddress); + if (expectedDestination !== actualDestination) { + return { + valid: false, + error: `Destination address mismatch: expected ${expectedDestination}, got ${actualDestination}`, + }; + } + } catch (err) { + return { + valid: false, + error: `Failed to decode bridge note data: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + return { + valid: true, + bridgeAmountNicks: bridgeOutput.assets.toString() as Nicks, + destinationAddress, + belts, + noteDataKey: validatedNoteDataKey, + version: validatedVersion, + chain: validatedChain, + bridgeLockRoot, + }; + } catch (err) { + return { + valid: false, + error: `Transaction validation failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +/** + * Validate and throw if invalid (convenience wrapper). + */ +export async function assertValidBridgeTransaction( + rawTx: RawTxV1, + context: 'pre-signing' | 'post-signing', + params: BridgeValidationParams, + config: BridgeConfig, + options: BuildBridgeTransactionOptions +): Promise { + const result = await validateBridgeTransaction(rawTx, params, config, options); + if (!result.valid) { + throw new Error(`${context} validation failed: ${result.error}`); + } + return result; +} + +// Re-export types +export type { + BridgeConfig, + BridgeTransactionParams, + BridgeTransactionResult, + BridgeValidationParams, + BridgeValidationResult, + BuildBridgeTransactionOptions, + TxEngineSettings, +} from './bridge-types.js'; diff --git a/src/compat.ts b/src/compat.ts new file mode 100644 index 0000000..3c48e15 --- /dev/null +++ b/src/compat.ts @@ -0,0 +1,375 @@ +/** + * Backward-compatibility helpers for SDK request payloads. + */ + +import type { + SendTransactionRequest, + NicksLike, + RpcRequest, + RpcResponse, + ConnectRequest, + ConnectResponse, + SignMessageRequest, + SignTxRequest, + SignMessageResponse, + SignTxResponse, +} from './types.js'; +import { + PbCom2RawTransaction, + PbCom2Note, + PbCom2SpendCondition, + rawTxFromProtobuf, + rawTxV1ToNockchainTx, + nockchainTxToRawTx, + rawTxToProtobuf, + rawTxInputSpendConditions, + publicKeyFromHex, + publicKeyToHex, + RawTxV1, + noteToProtobuf, + noteFromProtobuf, + spendConditionToProtobuf, + SpendCondition, + Digest, +} from './wasm.js'; +import * as guard from '@nockbox/iris-wasm/iris_wasm.guard'; +import { + PROVIDER_METHODS, + RPC_API_VERSION, + DEFAULT_TX_ENGINE_ACTIVATION_HEIGHTS, + DEFAULT_COINBASE_TIMELOCK_BLOCKS, +} from './constants.js'; + +/** + * Legacy `nock_signRawTx` RPC params (API 0): protobuf raw tx plus matching notes and spend conditions. + * Compat maps this to v1 `SIGN_TX` with a native `NockchainTx`. + * Notes are preserved as approval-display metadata; signing uses the canonical transaction. + */ +interface LegacySignRawTxRequest { + rawTx: PbCom2RawTransaction; + notes: PbCom2Note[]; + spendConditions: PbCom2SpendCondition[]; +} + +/** Type guard for the legacy `nock_signRawTx` payload shape. */ +function isLegacySignRawTxRequest(obj: unknown): obj is LegacySignRawTxRequest { + if (!obj || typeof obj !== 'object') return false; + const p = obj as { rawTx?: unknown; notes?: unknown; spendConditions?: unknown }; + return ( + guard.isPbCom2RawTransaction(p.rawTx) && + Array.isArray(p.notes) && + p.notes.length > 0 && + p.notes.every((n: unknown) => guard.isPbCom2Note(n)) && + Array.isArray(p.spendConditions) && + p.spendConditions.length > 0 && + p.spendConditions.every((sc: unknown) => guard.isPbCom2SpendCondition(sc)) + ); +} + +interface LegacyConnectResponse { + grpcEndpoint: string; + pkh: Digest; +} + +interface LegacySignMessageResponse { + signature: string; + publicKeyHex: string; +} + +/** Map an RPC request from one API version to another. */ +function mapRequest(request: RpcRequest, fromApi?: string, toApi?: string): RpcRequest { + if (fromApi === toApi) return request; + + const fromV1 = fromApi === RPC_API_VERSION; + const toV1 = toApi === RPC_API_VERSION; + + switch (request.method) { + case PROVIDER_METHODS.CONNECT: { + if (fromV1 && !toV1) { + // API 1 → legacy: { api: string } → [] + return { ...request, params: [] }; + } + if (!fromV1 && toV1) { + // legacy → API 1: [] → { api: string } + const connectParams: ConnectRequest = { api: RPC_API_VERSION }; + return { ...request, params: connectParams as unknown }; + } + return request; + } + case PROVIDER_METHODS.SEND_TRANSACTION: { + if (fromV1 && !toV1) { + const params = request.params as SendTransactionRequest | undefined; + return { ...request, params: params ? [params] : request.params }; + } + if (!fromV1 && toV1) { + const params = request.params as SendTransactionRequest[] | undefined; + return { ...request, params: params?.[0] }; + } + return request; + } + case PROVIDER_METHODS.SIGN_MESSAGE: { + if (fromV1 && !toV1) { + // API 1 → legacy: { message: string } → [message] + const params = request.params as unknown as SignMessageRequest | undefined; + return { ...request, params: params?.message ? [params.message] : request.params }; + } + if (!fromV1 && toV1) { + // legacy → API 1: [message] → { message: string } + const legacyParams = request.params as unknown as unknown[] | undefined; + const message = legacyParams?.[0] as string | undefined; + if (message) { + const signParams: SignMessageRequest = { message }; + return { ...request, params: signParams as unknown }; + } + } + return request; + } + case PROVIDER_METHODS.GET_WALLET_INFO: { + return request; + } + case 'nock_signRawTx': { + if (fromV1) { + throw new Error('signRawTx not implemented for API 1'); + } + if (toV1) { + const req = (request as any).params?.[0]; + if (!isLegacySignRawTxRequest(req)) { + throw new Error('Invalid legacyRawTx'); + } + const rawTx = rawTxFromProtobuf(req.rawTx); + if (!guard.isRawTxV1(rawTx)) { + throw new Error('Only V1 Raw TXs are supported at the moment'); + } + const tx = rawTxV1ToNockchainTx(rawTx); + const notes = req.notes.map(n => noteFromProtobuf(n)); + // `spendConditions` are validated above for legacy request compatibility. They are + // encoded in / derivable from the raw transaction, so the v1 request only carries + // notes as display metadata. + const signParams: SignTxRequest = { tx, notes }; + return { ...request, method: PROVIDER_METHODS.SIGN_TX, params: signParams as unknown }; + } + return request; + } + case PROVIDER_METHODS.SIGN_TX: { + if (!fromV1) { + throw new Error('signTx not implemented for API 0'); + } + if (!toV1) { + const req = request.params as unknown as SignTxRequest; + const rawTx = nockchainTxToRawTx(req.tx); + if (!req.notes) { + throw new Error('notes not found in SignTxRequest. This is required for API 0 wallets.'); + } + const notesNative = req.notes; + const notes = notesNative.map(note => noteToProtobuf(note)); + const spendConditionsNative = rawTxInputSpendConditions(rawTx); + const spendConditions = spendConditionsNative.map((spendCondition: SpendCondition) => + spendConditionToProtobuf(spendCondition) + ); + const legacyReq = { rawTx, notes, spendConditions }; + return { ...request, method: 'nock_signRawTx', params: [legacyReq] }; + } + return request; + } + default: + return request; + } +} + +/** + * Pure request mapper for callers that need request translation without invoking a target. + */ +export function mapRpcRequest( + request: RpcRequest, + sourceApi?: string, + targetApi?: string +): RpcRequest { + return mapRequest(request, sourceApi, targetApi); +} + +/** Map an RPC response from one API version to another. */ +function mapResponse( + method: string, + response: RpcResponse, + fromApi?: string, + toApi?: string +): RpcResponse { + if (fromApi === toApi) return response; + if (response.error) return response; + + const fromV1 = fromApi === RPC_API_VERSION; + const toV1 = toApi === RPC_API_VERSION; + + switch (method) { + case PROVIDER_METHODS.CONNECT: { + if (!fromV1 && toV1) { + // legacy → API 1: { grpcEndpoint, pkh } → { account, rpcConfig } + const legacy = response.result as LegacyConnectResponse; + const result: ConnectResponse = { + account: { type: 'v1', address: legacy.pkh }, + rpcConfig: { + rpcUrl: legacy.grpcEndpoint, + networkName: 'mainnet', + blockExplorerUrl: '', + txEngineActivationHeights: DEFAULT_TX_ENGINE_ACTIVATION_HEIGHTS, + coinbaseTimelockBlocks: DEFAULT_COINBASE_TIMELOCK_BLOCKS, + }, + }; + return { ...response, result }; + } + if (fromV1 && !toV1) { + // API 1 → legacy: { account, rpcConfig } → { grpcEndpoint, pkh } + const raw = response.result; + if (raw && typeof raw === 'object') { + const r = raw as Record; + // Extension may already return legacy `{ pkh, grpcEndpoint }` after internal + // handling; do not assume `account` exists (avoids reading undefined.type). + if ( + typeof r.pkh === 'string' && + typeof r.grpcEndpoint === 'string' && + !('account' in r) + ) { + return response; + } + } + const v1 = response.result as ConnectResponse; + if (!v1?.account || v1.account.type !== 'v1') { + throw new Error('Invalid account type'); + } + const result: LegacyConnectResponse = { + grpcEndpoint: v1.rpcConfig.rpcUrl, + pkh: v1.account.address, + }; + return { ...response, result }; + } + return response; + } + case PROVIDER_METHODS.SIGN_MESSAGE: { + if (!fromV1 && toV1) { + // legacy → API 1: { signature, publicKeyHex } → { signature, publicKey } + const legacy = response.result as LegacySignMessageResponse; + const signature = JSON.parse(legacy.signature) as { c: number[]; s: number[] }; + + const fromLegacyHex = (bytes: number[]): string => { + return bytes + .reverse() + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + }; + const publicKey = publicKeyFromHex(legacy.publicKeyHex); + if (!publicKey) { + throw new Error('Invalid public key'); + } + const result: SignMessageResponse = { + signature: { + c: fromLegacyHex(signature.c), + s: fromLegacyHex(signature.s), + }, + publicKey, + }; + return { ...response, result }; + } + if (fromV1 && !toV1) { + // API 1 → legacy: { signature, publicKey } → { signature, publicKeyHex } + const v1 = response.result as SignMessageResponse; + + const toLegacyHex = (v: string | Uint8Array): number[] => { + if (typeof v === 'string') { + let bytes = []; + for (let i = 0; i < v.length; i += 2) { + bytes.push(parseInt(v.substr(i, 2), 16)); + } + return bytes.reverse(); + } + return [...v]; + }; + const signatureJson = JSON.stringify({ + c: toLegacyHex(v1.signature.c), + s: toLegacyHex(v1.signature.s), + }); + + const result: LegacySignMessageResponse = { + signature: signatureJson, + publicKeyHex: publicKeyToHex(v1.publicKey), + }; + return { ...response, result }; + } + return response; + } + case PROVIDER_METHODS.SIGN_TX: { + if (fromV1 && !toV1) { + // Legacy callers reach this path via `nock_signRawTx → SIGN_TX` request + // remapping, and their historical contract was a bare + // `PbCom2RawTransaction` (fed straight into `rpcClient.sendTransaction`). + const v1 = response.result as SignTxResponse; + if (!v1?.tx) { + throw new Error('Invalid signTx response'); + } + const rawTx = nockchainTxToRawTx(v1.tx); + if (!guard.isRawTxV1(rawTx)) { + throw new Error('Only V1 Raw TXs are supported at the moment'); + } + return { ...response, result: rawTxToProtobuf(rawTx) }; + } + if (!fromV1 && toV1) { + // Accept both the historical bare shape and the previously-wrapped + // `{ rawTx }` shape. + const raw = response.result; + let legacyRawTx: PbCom2RawTransaction | undefined; + if (guard.isPbCom2RawTransaction(raw)) { + legacyRawTx = raw; + } else if (raw && typeof raw === 'object' && 'rawTx' in (raw as Record)) { + const wrapped = (raw as { rawTx?: unknown }).rawTx; + if (guard.isPbCom2RawTransaction(wrapped)) { + legacyRawTx = wrapped; + } + } + if (!legacyRawTx) { + throw new Error('Invalid legacy signRawTx response'); + } + const rawTx = rawTxFromProtobuf(legacyRawTx); + if (!guard.isRawTxV1(rawTx)) { + throw new Error('Only V1 Raw TXs are supported at the moment'); + } + const tx = rawTxV1ToNockchainTx(rawTx); + const result = { tx }; + return { ...response, result }; + } + return response; + } + default: + return response; + } +} + +/** + * Pure response mapper for callers that already executed a mapped request. + * + * `method` should be the method of the mapped request that produced `response`. + */ +export function mapRpcResponse( + method: string, + response: RpcResponse, + sourceApi?: string, + targetApi?: string +): RpcResponse { + return mapResponse(method, response, sourceApi, targetApi); +} + +/** + * Bridge RPC requests between two API versions, converting request params + * and response payloads as needed. + * + * Maps the request from sourceApi → targetApi, calls target, then maps + * the response back from targetApi → sourceApi (inverted). + */ +export async function requestBridge( + request: RpcRequest, + target: (request: RpcRequest) => Promise>, + sourceApi?: string, + targetApi?: string +): Promise> { + const mappedReq = mapRpcRequest(request, sourceApi, targetApi); + const res = await target(mappedReq); + return mapRpcResponse(mappedReq.method, res, targetApi, sourceApi) as RpcResponse; +} diff --git a/src/constants.ts b/src/constants.ts index cd2cfa0..3e41718 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,8 @@ +import { Nicks, TxEngineSettings } from '@nockbox/iris-wasm/iris_wasm'; + +/** Conversion rate: 1 NOCK = 65,536 nicks (2^16). */ +export const NOCK_TO_NICKS = 65_536; + /** * Provider method constants for Nockchain wallet * These methods can be called by dApps via window.nockchain @@ -15,8 +20,84 @@ export const PROVIDER_METHODS = { /** Get wallet information (PKH + gRPC endpoint) */ GET_WALLET_INFO: 'nock_getWalletInfo', - /** Sign a raw transaction */ - SIGN_RAW_TX: 'nock_signRawTx', + /** Sign a nockchain transaction */ + SIGN_TX: 'nock_signTx', } as const; export type ProviderMethod = (typeof PROVIDER_METHODS)[keyof typeof PROVIDER_METHODS]; + +export const RPC_API_VERSION = '1.0.0'; + +export const V0_TX_ENGINE_SETTINGS: TxEngineSettings = { + tx_engine_version: 0, + tx_engine_patch: 0, + min_fee: '0' as Nicks, + cost_per_word: '0' as Nicks, + witness_word_div: 0, +}; + +/** Default V1 tx engine settings (mainnet: v1 from 39000). */ +export const V1_TX_ENGINE_SETTINGS: TxEngineSettings = { + tx_engine_version: 1, + tx_engine_patch: 0, + min_fee: '256' as Nicks, + cost_per_word: String(1 << 15) as Nicks, + witness_word_div: 1, +}; + +/** Bythos tx engine (v1 patch 1): witness_word_div 4, min_fee 256 */ +export const BYTHOS_TX_ENGINE_SETTINGS: TxEngineSettings = { + tx_engine_version: 1, + tx_engine_patch: 1, + min_fee: '256' as Nicks, + cost_per_word: String(1 << 14) as Nicks, + witness_word_div: 4, +}; + +/** + * Default mainnet activation map + */ +export const DEFAULT_TX_ENGINE_ACTIVATION_HEIGHTS: Record = { + 1: V0_TX_ENGINE_SETTINGS, + 39000: V1_TX_ENGINE_SETTINGS, + 54000: BYTHOS_TX_ENGINE_SETTINGS, +}; + +/** + * Pick the latest tx engine from an activation-height map. + * Falls back to the SDK's default activation map when none is provided. + */ +export function getLatestTxEngineSettings( + activationHeights: Record = DEFAULT_TX_ENGINE_ACTIVATION_HEIGHTS +): TxEngineSettings { + const heights = Object.keys(activationHeights) + .map(Number) + .filter(Number.isFinite) + .sort((a, b) => a - b); + + if (heights.length === 0) { + return BYTHOS_TX_ENGINE_SETTINGS; + } + + return activationHeights[heights[heights.length - 1]]; +} + +/** Default coinbase maturity in blocks (mainnet-style; e.g. 100). */ +export const DEFAULT_COINBASE_TIMELOCK_BLOCKS = 100; + +/** Zorp bridge (Nockchain → Base) — minimum amount in NOCK (UI / validation guardrail). */ +export const MIN_BRIDGE_AMOUNT_NOCK = 100_000; + +/** Bridge protocol fee rate used for extension review display (0.3%). */ +export const BRIDGE_PROTOCOL_FEE_RATE = 0.003; + +/** Zorp bridge 3-of-5 multisig (Nockchain → Base). */ +export const ZORP_BRIDGE_THRESHOLD = 3; + +export const ZORP_BRIDGE_ADDRESSES: string[] = [ + 'AD6Mw1QUnPUrnVpyj2gW2jT6Jd6WsuZQmPn79XpZoFEocuvV12iDkvh', // Zorp #1 + '6KrZT5hHLY1fva9AUDeGtZu5Jznm4RDLYfjcGjuU49nWoNym5ZeX5X5', // Zorp #2 + 'CDLzgKWAKFXYABkuQaMwbttDSTDMh3Wy2Eoq2XiArsyxn7vScNHupBb', // Pero + '7E47xYNVEyt7jGmLsiChUHnyw88AfBvzJfXfEQkPmMo2ZWsdcPudwmV', // Nockbox + '3xSyK6RQUaYzE8YDUamkpKRHALxaYo8E7eppawwE4sP35c3PASc6koq', // SWPS +]; diff --git a/src/index.ts b/src/index.ts index dd6f7fe..9bdc74a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,11 @@ export * from './types.js'; export * from './provider.js'; -export * from './transaction.js'; +export * from './migration.js'; export * from './errors.js'; export * from './constants.js'; +export * from './validate-sign-tx-request.js'; +export * from './compat.js'; +export * from './bridge.js'; export * as wasm from './wasm.js'; +export { initWasm } from './wasm.js'; diff --git a/src/migration-types.ts b/src/migration-types.ts new file mode 100644 index 0000000..dfd12fa --- /dev/null +++ b/src/migration-types.ts @@ -0,0 +1,59 @@ +/** + * Types for querying v0 balance and building v0 -> v1 migration transactions. + * Aligned with @nockbox/iris-wasm (Nicks, NoteV0, RawTx, SpendCondition). + */ +import type { + LockRoot, + Nicks, + NoteV0, + PbCom2Balance, + RawTx, + SpendCondition, + TxEngineSettings, +} from '@nockbox/iris-wasm/iris_wasm.js'; + +export type { Nicks }; + +/** Result of querying v0 balance. Use this to construct a migration transaction. */ +export interface V0BalanceResult { + sourceAddress: string; + balance: PbCom2Balance; + v0Notes: NoteV0[]; + totalNicks: Nicks; + totalNock: number; + smallestNoteNock?: number; + rawNotesFromRpc?: number; +} + +/** + * Data needed to reconstruct a v0→v1 migration `TxBuilder` for signing (distinct from dApp `signRawTx` APIs). + */ +export interface V0MigrationTxSignPayload { + rawTx: RawTx; + notes: NoteV0[]; + spendConditions: (SpendCondition | null)[]; + refundLock: LockRoot; +} + +/** buildV0MigrationTx result: balance fields always present; tx fields when build succeeded. */ +export interface BuildV0MigrationTxResult { + sourceAddress: string; + balance: PbCom2Balance; + v0Notes: NoteV0[]; + totalNicks: Nicks; + totalNock: number; + smallestNoteNock?: number; + rawNotesFromRpc?: number; + txId?: string; + fee?: Nicks; + feeNock?: number; + v0MigrationTxSignPayload?: V0MigrationTxSignPayload; + migratedNicks?: Nicks; + migratedNock?: number; +} + +export interface BuildV0MigrationTxOptions { + txEngineSettings: TxEngineSettings; + /** Optional cap on how many smallest legacy notes to include. */ + maxNotes?: number; +} diff --git a/src/migration.ts b/src/migration.ts new file mode 100644 index 0000000..3aa299c --- /dev/null +++ b/src/migration.ts @@ -0,0 +1,277 @@ +import type { + BuildV0MigrationTxResult, + BuildV0MigrationTxOptions, + V0BalanceResult, + V0MigrationTxSignPayload, +} from './migration-types.js'; +import type { + Nicks, + NoteV0, + PbCom2Note, + PublicKey, + RawTxV1, + SpendCondition, + Digest, + Lock, + LockRoot, + TxEngineSettings, +} from '@nockbox/iris-wasm/iris_wasm.js'; +import { base58 } from '@scure/base'; +import * as wasm from './wasm.js'; +import * as guard from '@nockbox/iris-wasm/iris_wasm.guard'; +import { NOCK_TO_NICKS } from './constants.js'; + +function buildSinglePkhSpendCondition(pkh: Digest): SpendCondition { + const pkhObj = wasm.pkhSingle(pkh); + return wasm.spendConditionNewPkh(pkhObj); +} + +function sumNicks(notes: NoteV0[]): Nicks { + const total = notes.reduce((acc, note) => acc + BigInt(note.assets), 0n); + return total.toString() as Nicks; +} + +function parseV0Note(note?: PbCom2Note | null): NoteV0 | null { + if (!note) return null; + const parsed = wasm.noteFromProtobuf(note); + return guard.isNoteV0(parsed) ? parsed : null; +} + +function summarizeNote(note: NoteV0): { assetsNicks: string; assetsNock: number } { + const assets = BigInt(note.assets); + return { + assetsNicks: assets.toString(), + assetsNock: Number(assets) / NOCK_TO_NICKS, + }; +} + +/** + * Pick `count` notes in ascending value order. Prefer notes with value >= + * `minNicks` each (migration fee heuristic); if fewer than `count` qualify, + * fill remaining slots from the smallest notes overall (same order as + * `sortedNotes`). + */ +function selectNotesWithMinThreshold( + sortedNotes: Array<{ note: NoteV0; assets: bigint }>, + count: number, + minNicks: bigint +): NoteV0[] { + const out: NoteV0[] = []; + const used = new Set(); + + for (let i = 0; i < sortedNotes.length && out.length < count; i++) { + if (sortedNotes[i].assets >= minNicks) { + out.push(sortedNotes[i].note); + used.add(i); + } + } + for (let i = 0; i < sortedNotes.length && out.length < count; i++) { + if (used.has(i)) continue; + out.push(sortedNotes[i].note); + used.add(i); + } + return out; +} + +function appendV0MigrationSpends( + builder: wasm.TxBuilder, + notes: NoteV0[], + spendConditions: (SpendCondition | null)[] | undefined, + refundLock: LockRoot +): void { + for (let i = 0; i < notes.length; i++) { + const spendBuilder = new wasm.SpendBuilder( + notes[i], + (spendConditions?.[i] ?? null) as Lock | null, + null, + refundLock + ); + spendBuilder.computeRefund(false); + builder.spend(spendBuilder); + } +} + +/** + * Reconstruct a `TxBuilder` from {@link BuildV0MigrationTxResult.v0MigrationTxSignPayload}, + * using the same `txEngineSettings` that were used to build the unsigned tx (e.g. for fee iteration / signing). + */ +export function buildV0MigrationTxBuilderFromPayload( + payload: V0MigrationTxSignPayload, + txEngineSettings: TxEngineSettings +): wasm.TxBuilder { + const builder = new wasm.TxBuilder(txEngineSettings); + appendV0MigrationSpends(builder, payload.notes, payload.spendConditions, payload.refundLock); + return builder; +} + +/** + * Query v0 (Legacy) balance for a v0 public key. Discovery only; does not build a transaction. + * Caller must have initialized WASM (e.g. await wasm.default()) before using. + */ +export async function queryV0Balance( + sourcePublicKey: PublicKey, + grpcEndpoint: string +): Promise { + const sourceAddress = base58.encode(wasm.publicKeyToBeBytesVec(sourcePublicKey)); + const grpcClient = new wasm.GrpcClient(grpcEndpoint); + const balance = await grpcClient.getBalanceByAddress(sourceAddress); + + const v0Notes: NoteV0[] = []; + const entries = balance.notes ?? []; + for (const entry of entries) { + const parsedV0 = parseV0Note(entry.note); + if (parsedV0) v0Notes.push(parsedV0); + } + + const totalNicks = sumNicks(v0Notes); + const totalNock = Number(BigInt(totalNicks)) / NOCK_TO_NICKS; + const smallestNoteNock = + v0Notes.length > 0 + ? Number( + v0Notes.reduce( + (min, n) => (BigInt(n.assets) < min ? BigInt(n.assets) : min), + BigInt(v0Notes[0].assets) + ) + ) / NOCK_TO_NICKS + : undefined; + + return { + sourceAddress, + balance, + v0Notes, + totalNicks, + totalNock, + smallestNoteNock, + rawNotesFromRpc: entries.length, + }; +} + +/** + * Build a v0→v1 migration transaction to `targetV1Pkh` (after querying balance on-chain). + * For balance only, use {@link queryV0Balance}. + * Caller must have initialized WASM (e.g. await wasm.default()) before using. + * + * @param options.maxNotes - Optional cap on how many inputs to include (ascending + * by value). Each slot prefers notes with value >= 100 NOCK, then fills from + * the smallest notes overall if needed (same idea as single-note debug mode). + */ +export async function buildV0MigrationTx( + sourcePublicKey: PublicKey, + grpcEndpoint: string, + targetV1Pkh: Digest, + options: BuildV0MigrationTxOptions +): Promise { + const balanceResult = await queryV0Balance(sourcePublicKey, grpcEndpoint); + const maxNotes = options.maxNotes; + const debugSingleNote = + typeof maxNotes === 'number' && Number.isFinite(maxNotes) && Math.floor(maxNotes) === 1; + + try { + const v0Notes = balanceResult.v0Notes; + if (!v0Notes.length) { + throw new Error('No v0 notes to migrate'); + } + + const sortedNotes = [...v0Notes] + .map(note => ({ note, assets: BigInt(note.assets) })) + .sort((a, b) => (a.assets < b.assets ? -1 : a.assets > b.assets ? 1 : 0)); + + // Notes below ~100 NOCK often cannot cover the migration fee alone. For + // capped builds (`maxNotes`), prefer notes >= 100 NOCK (smallest first), + // then fill any remaining slots from the absolute smallest notes. + const MIN_NOTE_NICKS = BigInt(100) * BigInt(NOCK_TO_NICKS); + const singleNoteCandidates = sortedNotes.filter(entry => entry.assets >= MIN_NOTE_NICKS); + const singleNotePick = (singleNoteCandidates[0] ?? sortedNotes[0])?.note; + + const cappedCount = + typeof maxNotes === 'number' && Number.isFinite(maxNotes) && maxNotes > 0 + ? Math.floor(maxNotes) + : 0; + + const notesToUse: NoteV0[] = debugSingleNote + ? singleNotePick + ? [singleNotePick] + : [] + : cappedCount > 0 + ? selectNotesWithMinThreshold(sortedNotes, cappedCount, MIN_NOTE_NICKS) + : sortedNotes.map(entry => entry.note); + + if (debugSingleNote && singleNotePick) { + console.log('[SDK Migration] Single-note mode selected note:', { + pickedNote: summarizeNote(singleNotePick), + viaThreshold: singleNoteCandidates.length > 0, + thresholdNock: Number(MIN_NOTE_NICKS) / NOCK_TO_NICKS, + totalLegacyNotes: v0Notes.length, + notesAboveThreshold: singleNoteCandidates.length, + }); + } else if (cappedCount > 1) { + console.log('[SDK Migration] Capped-note mode selected notes:', { + requested: cappedCount, + pickedCount: notesToUse.length, + picked: notesToUse.slice(0, 10).map(summarizeNote), + thresholdNock: Number(MIN_NOTE_NICKS) / NOCK_TO_NICKS, + notesAboveThreshold: singleNoteCandidates.length, + totalLegacyNotes: v0Notes.length, + }); + } + + const targetSpendCondition = buildSinglePkhSpendCondition(targetV1Pkh); + const refundLock = wasm.lockHash(targetSpendCondition); + const builder = new wasm.TxBuilder(options.txEngineSettings); + appendV0MigrationSpends( + builder, + notesToUse, + notesToUse.map(() => null), + refundLock + ); + + builder.recalcAndSetFee(false); + const feeNicks = builder.curFee(); + const transaction = builder.build(); + const rawTx: RawTxV1 = wasm.nockchainTxToRawTx(transaction); + if (!guard.isRawTxV1(rawTx)) { + throw new Error('Built v0 migration transaction contains a spend without seeds'); + } + + const inputNotes = notesToUse; + const feeNock = Number(BigInt(feeNicks)) / NOCK_TO_NICKS; + const selectedTotal = inputNotes.reduce((sum, note) => sum + BigInt(note.assets), 0n); + const migratedNicks = (selectedTotal - BigInt(feeNicks)).toString() as Nicks; + const migratedNock = Number(selectedTotal) / NOCK_TO_NICKS - feeNock; + + const result: BuildV0MigrationTxResult = { + ...balanceResult, + txId: transaction.id, + fee: feeNicks, + feeNock, + v0MigrationTxSignPayload: { + rawTx, + notes: inputNotes, + spendConditions: inputNotes.map(() => null), + refundLock, + }, + migratedNicks, + migratedNock, + }; + return result; + } catch (e) { + console.warn('[SDK Migration] Build failed, returning balance only:', { + error: e instanceof Error ? e.message : String(e), + sourceAddress: balanceResult.sourceAddress, + rawNotesFromRpc: balanceResult.rawNotesFromRpc, + legacyV0Notes: balanceResult.v0Notes.length, + totalNicks: balanceResult.totalNicks, + smallestNoteNock: balanceResult.smallestNoteNock, + maxNotes, + singleNoteMode: debugSingleNote, + }); + return balanceResult; + } +} + +export type { + BuildV0MigrationTxOptions, + BuildV0MigrationTxResult, + V0BalanceResult, + V0MigrationTxSignPayload, +} from './migration-types.js'; diff --git a/src/provider.ts b/src/provider.ts index cb3ee83..cae3a11 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -2,10 +2,23 @@ * NockchainProvider - Main SDK class for interacting with Iris wallet */ -import type { Transaction, NockchainEvent, EventListener, InjectedNockchain } from './types.js'; -import { TransactionBuilder } from './transaction.js'; +import type { + Account, + ConnectResponse, + NockchainEvent, + EventListener, + InjectedNockchain, + SignTxResponse, + RpcRequest, + SignTxRequest, + SignMessageRequest, + SignMessageResponse, + ConnectRequest, + SendTransactionRequest, +} from './types.js'; import { WalletNotInstalledError, UserRejectedError, RpcError, NoAccountError } from './errors.js'; -import { PROVIDER_METHODS } from './constants.js'; +import { PROVIDER_METHODS, RPC_API_VERSION } from './constants.js'; +import { NockchainTx, Note } from '@nockbox/iris-wasm'; /** * NockchainProvider class - Main interface for dApps to interact with Iris wallet @@ -29,7 +42,7 @@ import { PROVIDER_METHODS } from './constants.js'; export class NockchainProvider { private injected: InjectedNockchain; private eventListeners: Map>; - private _accounts: string[] = []; + private _accounts: Account[] = []; private _chainId: string | null = null; private _messageHandler?: (event: MessageEvent) => void; @@ -68,21 +81,21 @@ export class NockchainProvider { * @throws {UserRejectedError} If the user rejects the request * @throws {RpcError} If the RPC call fails */ - async connect(): Promise<{ pkh: string; grpcEndpoint: string }> { - const info = await this.request<{ pkh: string; grpcEndpoint: string }>({ + async connect(): Promise { + const info = await this.request({ method: PROVIDER_METHODS.CONNECT, }); // Store the PKH as the connected account - this._accounts = [info.pkh]; + this._accounts = [info.account]; return info; } /** * Get the currently connected accounts (if any) - * @returns Array of connected account addresses (PKH) + * @returns Array of connected accounts */ - get accounts(): string[] { + get accounts(): Account[] { return [...this._accounts]; } @@ -110,14 +123,14 @@ export class NockchainProvider { * @throws {UserRejectedError} If the user rejects the transaction * @throws {RpcError} If the RPC call fails */ - async sendTransaction(transaction: Transaction): Promise { + async sendTransaction(transaction: SendTransactionRequest): Promise { if (!this.isConnected) { throw new NoAccountError(); } - return this.request({ + return this.request({ method: PROVIDER_METHODS.SEND_TRANSACTION, - params: [transaction], + params: transaction, }); } @@ -129,95 +142,44 @@ export class NockchainProvider { * @throws {UserRejectedError} If the user rejects the signing request * @throws {RpcError} If the RPC call fails */ - async signMessage(message: string): Promise<{ signature: string; publicKeyHex: string }> { + async signMessage(message: string): Promise { if (!this.isConnected) { throw new NoAccountError(); } - return this.request<{ signature: string; publicKeyHex: string }>({ + return this.request({ method: PROVIDER_METHODS.SIGN_MESSAGE, - params: [message], + params: { message }, }); } /** * Sign a raw transaction - * Accepts either wasm objects (with toProtobuf() method) or protobuf JS objects - * @param params - The transaction parameters (rawTx, notes, spendConditions) - * @returns Promise resolving to the signed raw transaction as protobuf Uint8Array + * Input must be NockchainTx. + * @param tx - The transaction to sign + * @param notes - Optional approval-display metadata for legacy compatibility + * @returns Promise resolving to the signed transaction * @throws {NoAccountError} If no account is connected * @throws {UserRejectedError} If the user rejects the signing request * @throws {RpcError} If the RPC call fails * * @example * ```typescript - * // Option 1: Pass wasm objects directly (auto-converts to protobuf) - * const rawTx = builder.build(); - * const txNotes = builder.allNotes(); - * - * const signedTx = await provider.signRawTx({ - * rawTx: rawTx, // wasm RawTx object - * notes: txNotes.notes, // array of wasm Note objects - * spendConditions: txNotes.spendConditions // array of wasm SpendCondition objects - * }); - * - * // Option 2: Pass protobuf JS objects directly - * const signedTx = await provider.signRawTx({ - * rawTx: rawTxProtobufObject, // protobuf JS object - * notes: noteProtobufObjects, // array of protobuf JS objects - * spendConditions: spendCondProtobufObjects // array of protobuf JS objects - * }); + * const nockchainTx = wasm.rawTxV1ToNockchainTx(rawTx); + * const signedTx = await provider.signTx(nockchainTx); * ``` */ - async signRawTx(params: { - rawTx: any; - notes: any[]; - spendConditions: any[]; - }): Promise { + async signTx(tx: NockchainTx, notes?: Note[]): Promise { if (!this.isConnected) { throw new NoAccountError(); } - // Helper to convert to protobuf if it's a wasm object - const toProtobuf = (obj: any): any => { - // If object has toProtobuf method, it's a wasm object - convert it - if (obj && typeof obj.toProtobuf === 'function') { - return obj.toProtobuf(); - } - // Otherwise assume it's already a protobuf JS object - return obj; - }; - - // Convert wasm objects to protobuf (if needed) - const protobufParams = { - rawTx: toProtobuf(params.rawTx), - notes: params.notes.map(toProtobuf), - spendConditions: params.spendConditions.map(toProtobuf), - }; - - return this.request({ - method: PROVIDER_METHODS.SIGN_RAW_TX, - params: [protobufParams], + return this.request({ + method: PROVIDER_METHODS.SIGN_TX, + params: { tx, notes }, }); } - /** - * Create a new transaction builder - * @returns A new TransactionBuilder instance - * - * @example - * ```typescript - * const tx = provider.transaction() - * .to('recipient_address') - * .amount(1_000_000) - * .fee(50_000) - * .build(); - * ``` - */ - transaction(): TransactionBuilder { - return new TransactionBuilder(); - } - /** * Add an event listener for wallet events * @param event - The event to listen for @@ -268,9 +230,12 @@ export class NockchainProvider { * @throws {UserRejectedError} If the user rejects the request * @throws {RpcError} If the RPC call fails */ - public async request(args: { method: string; params?: unknown[] }): Promise { + public async request(args: RpcRequest): Promise { try { - const result = await this.injected.request(args); + const result = await this.injected.request({ + ...args, + api: args.api ?? RPC_API_VERSION, + }); return result; } catch (error) { // Handle RPC errors and map known error codes diff --git a/src/transaction.ts b/src/transaction.ts deleted file mode 100644 index 58b8ab8..0000000 --- a/src/transaction.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Transaction builder with fluent API for constructing Nockchain transactions - */ - -import { base58 } from '@scure/base'; -import type { Transaction } from './types.js'; -import { InvalidAddressError, InvalidTransactionError } from './errors.js'; - -/** - * Conversion rate: 1 NOCK = 65,536 nicks (2^16) - */ -export const NOCK_TO_NICKS = 65_536; - -/** - * Default transaction fee in nicks (32,768 nicks = 0.5 NOCK) - */ -export const DEFAULT_FEE = 32_768; - -/** - * Minimum amount in nicks (must be positive) - */ -export const MIN_AMOUNT = 1; - -/** - * TransactionBuilder class implementing the builder pattern for type-safe transaction construction - * - * @example - * ```typescript - * const tx = new TransactionBuilder() - * .to('nock1recipient_address') - * .amount(1_000_000) - * .fee(50_000) - * .build(); - * ``` - */ -export class TransactionBuilder { - private _to?: string; - private _amount?: number; - private _fee?: number; - - /** - * Set the recipient address for the transaction - * @param address - Base58-encoded Nockchain V1 PKH address (40 bytes, ~54-55 chars) - * @returns A new TransactionBuilder instance with the address set - * @throws {InvalidAddressError} If the address format is invalid - */ - to(address: string): TransactionBuilder { - if (!this.isValidAddress(address)) { - throw new InvalidAddressError(address); - } - - const builder = new TransactionBuilder(); - builder._to = address; - builder._amount = this._amount; - builder._fee = this._fee; - return builder; - } - - /** - * Set the amount to send in nicks (1 NOCK = 65,536 nicks) - * @param nicks - Amount in nicks (must be a positive integer) - * @returns A new TransactionBuilder instance with the amount set - * @throws {InvalidTransactionError} If the amount is invalid - */ - amount(nicks: number): TransactionBuilder { - if (!Number.isInteger(nicks)) { - throw new InvalidTransactionError('Amount must be an integer'); - } - if (nicks < MIN_AMOUNT) { - throw new InvalidTransactionError(`Amount must be at least ${MIN_AMOUNT} nick`); - } - // Note: MIN_AMOUNT = 1, so the above check already covers negative amounts - - const builder = new TransactionBuilder(); - builder._to = this._to; - builder._amount = nicks; - builder._fee = this._fee; - return builder; - } - - /** - * Set the transaction fee in nicks (optional, defaults to 32,768 nicks) - * @param nicks - Fee amount in nicks (must be a positive integer) - * @returns A new TransactionBuilder instance with the fee set - * @throws {InvalidTransactionError} If the fee is invalid - */ - fee(nicks: number): TransactionBuilder { - if (!Number.isInteger(nicks)) { - throw new InvalidTransactionError('Fee must be an integer'); - } - if (nicks < 0) { - throw new InvalidTransactionError('Fee must be non-negative'); - } - - const builder = new TransactionBuilder(); - builder._to = this._to; - builder._amount = this._amount; - builder._fee = nicks; - return builder; - } - - /** - * Build and validate the transaction - * @returns The constructed Transaction object - * @throws {InvalidTransactionError} If required fields are missing - */ - build(): Transaction { - if (!this._to) { - throw new InvalidTransactionError('Missing required field: to (recipient address)'); - } - if (this._amount === undefined || this._amount === null) { - throw new InvalidTransactionError('Missing required field: amount'); - } - - return { - to: this._to, - amount: this._amount, - fee: this._fee ?? DEFAULT_FEE, - }; - } - - /** - * Validate a Nockchain V1 PKH address format - * V1 PKH addresses are TIP5 hash (40 bytes) of public key, base58-encoded - * - * Validates by decoding the base58 string and checking for exactly 40 bytes - * rather than relying on character count which can vary - * - * @param address - The address to validate - * @returns true if valid, false otherwise - */ - private isValidAddress(address: string): boolean { - try { - const trimmed = (address || '').trim(); - if (trimmed.length === 0) return false; - - const bytes = base58.decode(trimmed); - return bytes.length === 40; - } catch { - // Invalid base58 encoding - return false; - } - } - - /** - * Create a transaction builder from an existing transaction object - * Useful for modifying existing transactions - * @param tx - The transaction to create a builder from - * @returns A new TransactionBuilder instance with values from the transaction - * @throws {InvalidAddressError} If the transaction address is invalid - * @throws {InvalidTransactionError} If the transaction amount or fee is invalid - */ - static fromTransaction(tx: Transaction): TransactionBuilder { - // Use setters to ensure validation is applied - let builder = new TransactionBuilder().to(tx.to).amount(tx.amount); - - if (typeof tx.fee === 'number') { - builder = builder.fee(tx.fee); - } - - return builder; - } -} diff --git a/src/types.ts b/src/types.ts index 72544f2..c18d543 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,26 +2,23 @@ * TypeScript type definitions for Iris SDK */ +import { Nicks, TxEngineSettings, PublicKey, Signature, Digest, NockchainTx, Note } from './wasm'; + /** * Transaction object representing a Nockchain transaction */ -export interface Transaction { - /** Recipient address (base58-encoded public key hash / PKH) */ - to: string; - /** Amount to send in nicks (1 NOCK = 65,536 nicks) */ - amount: number; - /** Transaction fee in nicks (optional, defaults to 32,768 = 0.5 NOCK) */ - fee?: number; -} +export type NicksLike = number | Nicks | bigint; /** * RPC request object for communicating with the extension */ -export interface RpcRequest { +export interface RpcRequest { /** The RPC method to call */ method: string; + /** API version of this request payload (defaults to legacy API 0 when omitted) */ + api?: string; /** Optional parameters for the method */ - params?: unknown[]; + params?: T; /** Optional timeout for the request */ timeout?: number; } @@ -40,6 +37,69 @@ export interface RpcResponse { }; } +export interface ConnectRequest { + /** @deprecated API version now lives on RpcRequest.api */ + api?: string; +} + +export interface RpcConfig { + rpcUrl: string; + networkName: string; + blockExplorerUrl: string; + txEngineActivationHeights: Record; + coinbaseTimelockBlocks: number; +} + +export interface ConnectResponse { + account: Account; + rpcConfig: RpcConfig; +} + +export interface V0Account { + type: 'v0'; + address: PublicKey; +} + +export interface V1Account { + type: 'v1'; + address: Digest; +} + +export type Account = V0Account | V1Account; +export type Address = PublicKey | Digest; + +export interface SignMessageRequest { + message: string; +} + +export interface SignMessageResponse { + signature: Signature; + /** Base58 encoded public key */ + publicKey: PublicKey; +} + +export interface SendTransactionRequest { + /** Recipient address (base58-encoded public key hash / PKH) */ + to: Address; + /** Amount to send in nicks (legacy number + canonical string/bigint accepted) */ + amount: Nicks; + /** Transaction fee in nicks (legacy number + canonical string/bigint accepted) */ + fee?: Nicks; +} + +export interface SignTxRequest { + tx: NockchainTx; + /** + * Optional input notes supplied for approval display and API 0 wallet compatibility. + * The canonical signer consumes `tx`; notes are sidecar metadata. + */ + notes?: Note[]; +} + +export interface SignTxResponse { + tx: NockchainTx; +} + /** * Event types that the provider can emit */ @@ -59,7 +119,7 @@ export interface InjectedNockchain { * @param request - The RPC request object * @returns Promise resolving to the result */ - request(request: RpcRequest): Promise; + request(request: RpcRequest): Promise; /** * Provider name (e.g., 'iris') @@ -70,6 +130,11 @@ export interface InjectedNockchain { * Provider version */ version?: string; + + /** + * Supported RPC API version + */ + api?: string; } /** diff --git a/src/validate-sign-tx-request.ts b/src/validate-sign-tx-request.ts new file mode 100644 index 0000000..7ffa3b3 --- /dev/null +++ b/src/validate-sign-tx-request.ts @@ -0,0 +1,16 @@ +/** + * Runtime validation for `SignTxRequest` at RPC / postMessage boundaries. + */ + +import * as guard from '@nockbox/iris-wasm/iris_wasm.guard'; +import type { SignTxRequest } from './types.js'; + +export function isSignTxRequest(obj: unknown): obj is SignTxRequest { + if (!obj || typeof obj !== 'object') return false; + const p = obj as { tx?: unknown; notes?: unknown }; + return ( + guard.isNockchainTx(p.tx) && + (typeof p.notes === 'undefined' || + (Array.isArray(p.notes) && p.notes.every((note: unknown) => guard.isNote(note)))) + ); +} diff --git a/src/wasm.ts b/src/wasm.ts index 12313e9..966c8ff 100644 --- a/src/wasm.ts +++ b/src/wasm.ts @@ -2,5 +2,14 @@ * WASM module exports */ -export * from '@nockbox/iris-wasm/iris_wasm.js'; -export { default } from '@nockbox/iris-wasm/iris_wasm.js'; +export * from '@nockbox/iris-wasm/iris_wasm'; +export { default } from '@nockbox/iris-wasm/iris_wasm'; +export * as guard from '@nockbox/iris-wasm/iris_wasm.guard'; + +import init from '@nockbox/iris-wasm/iris_wasm'; + +/** + * Canonical initializer re-export for SDK consumers. + * Prefer using this instead of importing iris-wasm directly. + */ +export const initWasm = init;