From 56794119fd42bd97946adadd7cafd0ef9345d6b6 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:50:30 -0500 Subject: [PATCH 01/11] added APIs for v0 to v1 migration and swap (bridge) to be consumed by the extension --- src/bridge-types.ts | 82 +++++++++ src/bridge.ts | 388 +++++++++++++++++++++++++++++++++++++++++ src/migration-types.ts | 60 +++++++ src/migration.ts | 117 +++++++++++++ 4 files changed, 647 insertions(+) create mode 100644 src/bridge-types.ts create mode 100644 src/bridge.ts create mode 100644 src/migration-types.ts create mode 100644 src/migration.ts diff --git a/src/bridge-types.ts b/src/bridge-types.ts new file mode 100644 index 0000000..c0b0c9d --- /dev/null +++ b/src/bridge-types.ts @@ -0,0 +1,82 @@ +/** + * 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 } from '@nockbox/iris-wasm/iris_wasm.js'; + +export type { Nicks }; + +/** + * 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; + /** Fee per word in nicks (WASM Nicks = string, e.g. "32768") */ + feePerWord: Nicks; + /** 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; + /** Optional fee override in nicks */ + feeOverride?: Nicks; +} + +/** + * 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; +} diff --git a/src/bridge.ts b/src/bridge.ts new file mode 100644 index 0000000..59618ab --- /dev/null +++ b/src/bridge.ts @@ -0,0 +1,388 @@ +/** + * Bridge utilities for Nockchain ↔ EVM bridging. + * Encoding uses the Goldilocks prime field (3 belts) for EVM addresses. + * Consumers provide BridgeConfig; the SDK handles transaction construction and validation. + */ + +import type { + BridgeConfig, + BridgeTransactionParams, + BridgeTransactionResult, + BridgeValidationResult, +} from './bridge-types.js'; +import type { + LockRoot, + Note, + NoteData, + Noun, + PbCom2RawTransaction, + SeedV1, + SpendCondition, +} from '@nockbox/iris-wasm/iris_wasm.js'; +import * as wasm from './wasm.js'; + +// Goldilocks prime: 2^64 - 2^32 + 1 +export const GOLDILOCKS_PRIME = 2n ** 64n - 2n ** 32n + 1n; + +/** 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); +} + +/** + * Convert an EVM address to 3 belts (Goldilocks field elements). + */ +export function evmAddressToBelts(address: string): [bigint, bigint, bigint] { + if (!isEvmAddress(address)) { + throw new Error(`Invalid EVM address: ${address}`); + } + const normalized = address.startsWith('0x') ? address : `0x${address}`; + const addr = BigInt(normalized); + + const belt1 = addr % GOLDILOCKS_PRIME; + const q1 = addr / GOLDILOCKS_PRIME; + const belt2 = q1 % GOLDILOCKS_PRIME; + const belt3 = q1 / GOLDILOCKS_PRIME; + + return [belt1, belt2, belt3]; +} + +/** + * Convert 3 belts back to an EVM address. + */ +export function beltsToEvmAddress(belt1: bigint, belt2: bigint, belt3: bigint): string { + const p = GOLDILOCKS_PRIME; + const address = belt1 + belt2 * p + belt3 * p * p; + return '0x' + address.toString(16).padStart(40, '0'); +} + +/** Encode a string as a Hoon cord (little-endian hex). */ +export function stringToAtom(str: string): string { + const bytes = new TextEncoder().encode(str); + let hex = ''; + for (let i = bytes.length - 1; i >= 0; i--) { + hex += bytes[i].toString(16).padStart(2, '0'); + } + return hex || '0'; +} + +/** 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. + * Structure: [versionTag [chainTag [belt1 [belt2 belt3]]]] + */ +export function buildBridgeNoun( + evmAddress: string, + config: Pick +): unknown { + const [belt1, belt2, belt3] = evmAddressToBelts(evmAddress); + return [ + config.versionTag, + [config.chainTag, [bigintToAtom(belt1), [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 + ); +} + +/** + * 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 as Noun); +} + +/** + * 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 +): Promise { + if (!isBridgeConfigured(config)) { + throw new Error('Bridge not configured'); + } + if (!isEvmAddress(params.destinationAddress)) { + throw new Error(`Invalid destination address: ${params.destinationAddress}`); + } + + const bridgeNounJs = buildBridgeNoun(params.destinationAddress, config); + const noteData: NoteData = [[config.noteDataKey, bridgeNounJs as Noun]]; + + const bridgeSpendCondition: SpendCondition = [ + { Pkh: { m: config.threshold, hashes: config.addresses } }, + ]; + const bridgeLockRoot: LockRoot = { Lock: bridgeSpendCondition }; + const refundLock: SpendCondition = [{ Pkh: { m: 1, hashes: [params.refundPkh] } }]; + + const costPerWord = params.feeOverride ?? config.feePerWord; + const builder = new wasm.TxBuilder({ + tx_engine_version: 1, + tx_engine_patch: 0, + min_fee: '256', + cost_per_word: costPerWord, + witness_word_div: 1, + }); + + for (let i = 0; i < params.inputNotes.length; i++) { + const note = params.inputNotes[i]; + const spendCondition = params.spendConditions[i]; + + const spendBuilder = new wasm.SpendBuilder(note, spendCondition, refundLock); + + const parentHash = wasm.note_hash(note); + const seed: SeedV1 = { + output_source: undefined, + lock_root: bridgeLockRoot, + note_data: noteData, + gift: params.amountInNicks, + parent_hash: parentHash, + }; + + spendBuilder.seed(seed); + spendBuilder.computeRefund(false); + builder.spend(spendBuilder); + } + + builder.recalcAndSetFee(false); + const feeResult = builder.calcFee(); + const transaction = builder.build(); + + const txId = transaction.id; + const fee = feeResult; + + return { + transaction, + txId, + fee, + }; +} + +/** + * Validate a bridge transaction (pre- or post-signing). + * Uses config for note key, min amount, and optional lock root. + */ +export async function validateBridgeTransaction( + rawTxProto: unknown, + config: BridgeConfig +): Promise { + try { + const rawTx = wasm.rawTxFromProtobuf(rawTxProto as PbCom2RawTransaction); + const outputs = wasm.rawTxOutputs(rawTx); + + if (outputs.length === 0) { + return { valid: false, error: 'Transaction has no outputs' }; + } + + const outputData: Array<{ + assets: bigint; + noteData: NoteData; + }> = outputs.map((output: Note) => { + const noteData = 'note_data' in output ? ((output.note_data as NoteData) ?? []) : []; + return { + assets: BigInt(output.assets ?? 0), + noteData, + }; + }); + + let bridgeOutput: (typeof outputData)[0] | null = null; + for (const output of outputData) { + const hasKey = output.noteData?.some( + (entry: [string, Noun]) => entry[0] === config.noteDataKey + ); + if (hasKey) { + bridgeOutput = output; + 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 (!bridgeOutput.noteData?.length) { + return { valid: false, error: 'Bridge output missing note data' }; + } + + const bridgeEntryPair = bridgeOutput.noteData.find( + (entry: [string, Noun]) => entry[0] === config.noteDataKey + ); + if (!bridgeEntryPair) { + return { + valid: false, + error: `Bridge output missing '${config.noteDataKey}' note data entry`, + }; + } + const bridgeEntryValue = bridgeEntryPair[1]; + + let destinationAddress: string | undefined; + let belts: [bigint, bigint, bigint] | undefined; + let validatedVersion: string | undefined; + let validatedChain: string | undefined; + const validatedNoteDataKey = bridgeEntryPair[0]; + + try { + const value = bridgeEntryValue as unknown; + const decoded: unknown = Array.isArray(value) + ? value + : wasm.cue( + value instanceof Uint8Array ? value : new Uint8Array(value as unknown as number[]) + ); + + if (!Array.isArray(decoded) || decoded.length !== 2) { + return { + valid: false, + error: 'Invalid bridge note data structure: expected [version, [chain, belts]]', + }; + } + + const version = decoded[0]; + if (version !== config.versionTag && version !== Number(config.versionTag)) { + return { + valid: false, + error: `Invalid bridge note data version: expected ${config.versionTag}, got ${version}`, + }; + } + validatedVersion = String(version); + + const chainAndBelts = decoded[1]; + if (!Array.isArray(chainAndBelts) || chainAndBelts.length !== 2) { + return { + valid: false, + error: 'Invalid bridge note data: missing chain and belts', + }; + } + + const chain = chainAndBelts[0]; + if (String(chain) !== config.chainTag) { + return { + valid: false, + error: `Invalid bridge chain: expected ${config.chainTag}, got ${chain}`, + }; + } + validatedChain = String(chain); + + const beltData = chainAndBelts[1]; + if (!Array.isArray(beltData) || beltData.length !== 2) { + return { + valid: false, + error: 'Invalid bridge note data: invalid belt structure', + }; + } + + const belt1Hex = beltData[0]; + const belt2And3 = beltData[1]; + if (!Array.isArray(belt2And3) || belt2And3.length !== 2) { + return { + valid: false, + error: 'Invalid bridge note data: invalid belt2/belt3 structure', + }; + } + + const belt2Hex = belt2And3[0]; + const belt3Hex = belt2And3[1]; + + const belt1 = BigInt('0x' + belt1Hex); + const belt2 = BigInt('0x' + belt2Hex); + const belt3 = BigInt('0x' + belt3Hex); + belts = [belt1, belt2, belt3]; + destinationAddress = beltsToEvmAddress(belt1, belt2, belt3); + + if (!isEvmAddress(destinationAddress)) { + return { + valid: false, + error: `Reconstructed address is invalid: ${destinationAddress}`, + }; + } + } catch (err) { + return { + valid: false, + error: `Failed to decode bridge note data: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + return { + valid: true, + bridgeAmountNicks: String(bridgeOutput.assets), + destinationAddress, + belts, + noteDataKey: validatedNoteDataKey, + version: validatedVersion, + chain: validatedChain, + }; + } 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( + rawTxProto: unknown, + context: 'pre-signing' | 'post-signing', + config: BridgeConfig +): Promise { + const result = await validateBridgeTransaction(rawTxProto, config); + if (!result.valid) { + throw new Error(`${context} validation failed: ${result.error}`); + } + return result; +} + +// Re-export types +export type { + BridgeConfig, + BridgeTransactionParams, + BridgeTransactionResult, + BridgeValidationResult, +} from './bridge-types.js'; diff --git a/src/migration-types.ts b/src/migration-types.ts new file mode 100644 index 0000000..8685a3b --- /dev/null +++ b/src/migration-types.ts @@ -0,0 +1,60 @@ +/** + * Types for querying v0 balance and building v0 -> v1 migration transactions. + * Aligned with @nockbox/iris-wasm (Nicks, NockchainTx, NoteV0, RawTx, SpendCondition). + */ +import type { + NockchainTx, + Nicks, + NoteV0, + PbCom2Balance, + RawTx, + SpendCondition, +} from '@nockbox/iris-wasm/iris_wasm.js'; + +export type { Nicks }; + +/** Input required to query legacy (v0) notes for a seed-derived PKH. */ +export interface QueryV0BalanceParams { + /** gRPC endpoint to query notes from. */ + grpcEndpoint: string; + /** PKH derived from the user's seedphrase. */ + sourcePkh: string; +} + +/** Result of querying v0 balance via first-name lookup. */ +export interface QueryV0BalanceResult { + /** Optional first-name digest (not required when querying by address). */ + firstName?: string; + /** Raw balance payload from gRPC. */ + balance: PbCom2Balance; + /** Parsed v0 notes found in the balance response. */ + v0Notes: NoteV0[]; +} + +/** Input for building a migration transaction from v0 notes to a v1 PKH address. */ +export interface BuildV0MigrationTransactionParams { + /** Legacy notes to migrate (typically from queryV0BalanceForPkh). */ + v0Notes: NoteV0[]; + /** Destination v1 PKH (usually the extension wallet PKH). */ + targetV1Pkh: string; + /** Fee-per-word used by TxBuilder (WASM Nicks = string, e.g. "32768"). */ + feePerWord?: Nicks; + /** Whether to include lock metadata in refund/migration seeds. */ + includeLockData?: boolean; +} + +/** Result of building a v0 -> v1 migration transaction. */ +export interface BuildV0MigrationTransactionResult { + /** Built transaction object returned by iris-wasm TxBuilder. */ + transaction: NockchainTx; + /** Transaction id (normalized as a string). */ + txId: string; + /** Calculated fee in nicks (WASM Nicks = string). */ + fee: Nicks; + /** Payload compatible with provider.signRawTx(...) */ + signRawTxPayload: { + rawTx: RawTx; + notes: NoteV0[]; + spendConditions: SpendCondition[]; + }; +} diff --git a/src/migration.ts b/src/migration.ts new file mode 100644 index 0000000..2ffefb6 --- /dev/null +++ b/src/migration.ts @@ -0,0 +1,117 @@ +import type { + BuildV0MigrationTransactionParams, + BuildV0MigrationTransactionResult, + QueryV0BalanceParams, + QueryV0BalanceResult, +} from './migration-types.js'; +import type { + Note, + NoteV0, + PbCom2BalanceEntry, + RawTxV1, + SpendCondition, + TxEngineSettings, + TxNotes, +} from '@nockbox/iris-wasm/iris_wasm.js'; +import * as wasm from './wasm.js'; + +function buildSinglePkhSpendCondition(pkh: string): SpendCondition { + return [{ Pkh: { m: 1, hashes: [pkh] } }]; +} + +function isLegacyEntry(entry: PbCom2BalanceEntry): boolean { + return !!entry.note?.note_version && 'Legacy' in entry.note.note_version; +} + +function isNoteV0(note: Note): note is NoteV0 { + return 'inner' in note && 'sig' in note && 'source' in note; +} + +/** + * Query address balance and return only v0 (Legacy) notes. + * Caller must have initialized WASM (e.g. await wasm.default()) before using. + */ +export async function queryV0BalanceForPkh( + params: QueryV0BalanceParams +): Promise { + const grpcClient = new wasm.GrpcClient(params.grpcEndpoint); + const balance = await grpcClient.getBalanceByAddress(params.sourcePkh); + + const v0Notes: NoteV0[] = []; + const entries = balance.notes ?? []; + for (const entry of entries) { + if (!isLegacyEntry(entry) || !entry.note) { + continue; + } + const parsed = wasm.note_from_protobuf(entry.note); + if (isNoteV0(parsed)) { + v0Notes.push(parsed); + } + } + + return { + balance, + v0Notes, + }; +} + +/** + * Build a transaction that migrates v0 notes into a v1 PKH lock. + * Caller must have initialized WASM (e.g. await wasm.default()) before using. + */ +export async function buildV0MigrationTransaction( + params: BuildV0MigrationTransactionParams +): Promise { + if (!params.v0Notes.length) { + throw new Error('No v0 notes provided for migration'); + } + + const includeLockData = !!params.includeLockData; + const costPerWord = params.feePerWord ?? '32768'; + + const targetSpendCondition = buildSinglePkhSpendCondition(params.targetV1Pkh); + const settings: TxEngineSettings = { + tx_engine_version: 1, + tx_engine_patch: 0, + min_fee: '256', + cost_per_word: costPerWord, + witness_word_div: 1, + }; + const builder = new wasm.TxBuilder(settings); + + for (const note of params.v0Notes) { + const spendBuilder = new wasm.SpendBuilder(note, null, targetSpendCondition); + // Use refund path to migrate full note value into target lock. + spendBuilder.computeRefund(includeLockData); + builder.spend(spendBuilder); + } + + builder.recalcAndSetFee(includeLockData); + const feeResult = builder.calcFee(); + const transaction = builder.build(); + const txNotes = builder.allNotes() as TxNotes; + const txId = transaction.id; + const rawTx: RawTxV1 = { + version: 1, + id: transaction.id, + spends: transaction.spends, + }; + + return { + transaction, + txId, + fee: feeResult, + signRawTxPayload: { + rawTx, + notes: txNotes.notes.filter((note): note is NoteV0 => isNoteV0(note)), + spendConditions: txNotes.spend_conditions, + }, + }; +} + +export type { + BuildV0MigrationTransactionParams, + BuildV0MigrationTransactionResult, + QueryV0BalanceParams, + QueryV0BalanceResult, +} from './migration-types.js'; From 61bea2ed4e25bcc53f83b09694988b86730623ad Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:20:06 -0500 Subject: [PATCH 02/11] temporary WASM module --- package-lock.json | 11 +++++++++++ package.json | 4 ++-- src/bridge.ts | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 543009b..c2244eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,12 @@ "vite": "^5.0.0" } }, + "../iris-rs/crates/iris-wasm/pkg": { + "name": "@nockbox/iris-wasm", + "version": "0.2.0-alpha.3", + "extraneous": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1007,6 +1013,11 @@ "optional": true } } + }, + "vendor/iris-wasm": { + "name": "@nockbox/iris-wasm", + "version": "0.2.0-alpha.3", + "license": "MIT" } } } diff --git a/package.json b/package.json index 694e709..812c6a7 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,10 @@ }, "files": [ "dist", - "README.md" + "README.md", + "vendor/iris-wasm" ], "scripts": { - "prepare": "npm run build", "build": "tsc", "build-example": "npm run build && tsc --project examples/tsconfig.json && vite build --config vite.config.examples.ts", "dev": "tsc --watch", diff --git a/src/bridge.ts b/src/bridge.ts index 59618ab..827de22 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -166,7 +166,7 @@ export async function buildBridgeTransaction( const parentHash = wasm.note_hash(note); const seed: SeedV1 = { - output_source: undefined, + output_source: null, lock_root: bridgeLockRoot, note_data: noteData, gift: params.amountInNicks, From e3fd27ef1268864c3d01624f6e6fe3ed8a408c30 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:56:27 -0500 Subject: [PATCH 03/11] update migration --- src/index.ts | 1 + src/migration-types.ts | 59 +++++++++++++++++++++++-- src/migration.ts | 98 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index f9e0d03..966d44d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ 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 './compat.js'; diff --git a/src/migration-types.ts b/src/migration-types.ts index 8685a3b..68c0adc 100644 --- a/src/migration-types.ts +++ b/src/migration-types.ts @@ -13,12 +13,43 @@ import type { export type { Nicks }; -/** Input required to query legacy (v0) notes for a seed-derived PKH. */ +/** Input required to derive the legacy (v0) address from seedphrase. */ +export interface DeriveV0AddressParams { + /** BIP-39 mnemonic for the legacy wallet. */ + mnemonic: string; + /** Optional BIP-39 passphrase. */ + passphrase?: string; + /** + * Optional child derivation index. + * If omitted, the master key public key is used. + */ + childIndex?: number; +} + +/** Derived legacy address metadata used for v0 discovery. */ +export interface DerivedV0Address { + /** Base58-encoded bare public key (legacy address form used by v0 notes). */ + sourceAddress: string; + /** PKH digest for the same public key (base58 digest string). */ + sourcePkh: string; + /** Public key bytes encoded as hex (debug/inspection helper). */ + publicKeyHex: string; +} + +/** Input required to query legacy (v0) notes by address. */ export interface QueryV0BalanceParams { /** gRPC endpoint to query notes from. */ grpcEndpoint: string; - /** PKH derived from the user's seedphrase. */ - sourcePkh: string; + /** + * Legacy base58 address (bare pubkey). + * Preferred field. + */ + sourceAddress?: string; + /** + * Back-compat alias. Historically named as PKH, but routed to getBalanceByAddress. + * If sourceAddress is provided, this field is ignored. + */ + sourcePkh?: string; } /** Result of querying v0 balance via first-name lookup. */ @@ -29,6 +60,28 @@ export interface QueryV0BalanceResult { balance: PbCom2Balance; /** Parsed v0 notes found in the balance response. */ v0Notes: NoteV0[]; + /** Sum of v0 note assets in nicks (string for bigint-safe transport). */ + totalNicks: Nicks; +} + +/** Input required to derive v0 address and immediately query legacy notes. */ +export interface QueryV0BalanceFromMnemonicParams extends DeriveV0AddressParams { + /** gRPC endpoint to query notes from. */ + grpcEndpoint: string; +} + +/** Result of mnemonic-based v0 note discovery. */ +export interface QueryV0BalanceFromMnemonicResult extends QueryV0BalanceResult, DerivedV0Address {} + +/** Inputs to build a migration transaction directly from mnemonic + destination. */ +export interface BuildV0MigrationFromMnemonicParams + extends QueryV0BalanceFromMnemonicParams, + Omit {} + +/** Output of mnemonic-based migration transaction building. */ +export interface BuildV0MigrationFromMnemonicResult extends BuildV0MigrationTransactionResult { + /** Discovery data used to assemble the transaction. */ + discovery: QueryV0BalanceFromMnemonicResult; } /** Input for building a migration transaction from v0 notes to a v1 PKH address. */ diff --git a/src/migration.ts b/src/migration.ts index 2ffefb6..dc61522 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -1,7 +1,13 @@ import type { + BuildV0MigrationFromMnemonicParams, + BuildV0MigrationFromMnemonicResult, BuildV0MigrationTransactionParams, BuildV0MigrationTransactionResult, + DeriveV0AddressParams, + DerivedV0Address, QueryV0BalanceParams, + QueryV0BalanceFromMnemonicParams, + QueryV0BalanceFromMnemonicResult, QueryV0BalanceResult, } from './migration-types.js'; import type { @@ -13,6 +19,7 @@ import type { TxEngineSettings, TxNotes, } from '@nockbox/iris-wasm/iris_wasm.js'; +import { base58 } from '@scure/base'; import * as wasm from './wasm.js'; function buildSinglePkhSpendCondition(pkh: string): SpendCondition { @@ -27,15 +34,47 @@ function isNoteV0(note: Note): note is NoteV0 { return 'inner' in note && 'sig' in note && 'source' in note; } +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); +} + +function sumNicks(notes: NoteV0[]): string { + const total = notes.reduce((acc, note) => acc + BigInt(note.assets), 0n); + return total.toString(); +} + +/** + * Derive legacy v0 address metadata from mnemonic. + * + * v0 discovery queries use the base58-encoded bare public key ("sourceAddress"). + * We also expose the hashed PKH digest for callers that need lock condition metadata. + */ +export function deriveV0AddressFromMnemonic(params: DeriveV0AddressParams): DerivedV0Address { + const master = wasm.deriveMasterKeyFromMnemonic(params.mnemonic, params.passphrase ?? ''); + const key = params.childIndex === undefined ? master : master.deriveChild(params.childIndex); + const publicKey = Uint8Array.from(key.publicKey); + + return { + sourceAddress: base58.encode(publicKey), + sourcePkh: wasm.hashPublicKey(publicKey), + publicKeyHex: toHex(publicKey), + }; +} + /** * Query address balance and return only v0 (Legacy) notes. * Caller must have initialized WASM (e.g. await wasm.default()) before using. */ -export async function queryV0BalanceForPkh( +export async function queryV0BalanceForAddress( params: QueryV0BalanceParams ): Promise { + const sourceAddress = params.sourceAddress ?? params.sourcePkh; + if (!sourceAddress) { + throw new Error('sourceAddress is required'); + } + const grpcClient = new wasm.GrpcClient(params.grpcEndpoint); - const balance = await grpcClient.getBalanceByAddress(params.sourcePkh); + const balance = await grpcClient.getBalanceByAddress(sourceAddress); const v0Notes: NoteV0[] = []; const entries = balance.notes ?? []; @@ -52,6 +91,35 @@ export async function queryV0BalanceForPkh( return { balance, v0Notes, + totalNicks: sumNicks(v0Notes), + }; +} + +/** + * Back-compat alias kept for older callers. + * Despite the name, this resolves to address-based lookup via getBalanceByAddress. + */ +export async function queryV0BalanceForPkh( + params: QueryV0BalanceParams +): Promise { + return queryV0BalanceForAddress(params); +} + +/** + * Derive v0 discovery address from mnemonic and query legacy notes in one step. + */ +export async function queryV0BalanceFromMnemonic( + params: QueryV0BalanceFromMnemonicParams +): Promise { + const derived = deriveV0AddressFromMnemonic(params); + const queried = await queryV0BalanceForAddress({ + grpcEndpoint: params.grpcEndpoint, + sourceAddress: derived.sourceAddress, + }); + + return { + ...derived, + ...queried, }; } @@ -109,9 +177,35 @@ export async function buildV0MigrationTransaction( }; } +/** + * Derive v0 address, query legacy notes, and build migration transaction. + */ +export async function buildV0MigrationFromMnemonic( + params: BuildV0MigrationFromMnemonicParams +): Promise { + const discovery = await queryV0BalanceFromMnemonic(params); + const built = await buildV0MigrationTransaction({ + v0Notes: discovery.v0Notes, + targetV1Pkh: params.targetV1Pkh, + feePerWord: params.feePerWord, + includeLockData: params.includeLockData, + }); + + return { + ...built, + discovery, + }; +} + export type { + BuildV0MigrationFromMnemonicParams, + BuildV0MigrationFromMnemonicResult, BuildV0MigrationTransactionParams, BuildV0MigrationTransactionResult, + DeriveV0AddressParams, + DerivedV0Address, QueryV0BalanceParams, + QueryV0BalanceFromMnemonicParams, + QueryV0BalanceFromMnemonicResult, QueryV0BalanceResult, } from './migration-types.js'; From 940bd481f09642c78047093033bc23309b1c1089 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:35:12 -0500 Subject: [PATCH 04/11] refactored and simplified the migration logic and the SDK API --- src/migration-types.ts | 91 +++++------------------------ src/migration.ts | 128 ++++++++++++++++++++--------------------- 2 files changed, 75 insertions(+), 144 deletions(-) diff --git a/src/migration-types.ts b/src/migration-types.ts index 68c0adc..1aa3694 100644 --- a/src/migration-types.ts +++ b/src/migration-types.ts @@ -13,101 +13,38 @@ import type { export type { Nicks }; -/** Input required to derive the legacy (v0) address from seedphrase. */ -export interface DeriveV0AddressParams { - /** BIP-39 mnemonic for the legacy wallet. */ - mnemonic: string; - /** Optional BIP-39 passphrase. */ - passphrase?: string; - /** - * Optional child derivation index. - * If omitted, the master key public key is used. - */ - childIndex?: number; -} - -/** Derived legacy address metadata used for v0 discovery. */ +/** Legacy address metadata derived from mnemonic. */ export interface DerivedV0Address { - /** Base58-encoded bare public key (legacy address form used by v0 notes). */ + /** Base58 bare public key (legacy address form). */ sourceAddress: string; - /** PKH digest for the same public key (base58 digest string). */ - sourcePkh: string; - /** Public key bytes encoded as hex (debug/inspection helper). */ - publicKeyHex: string; -} - -/** Input required to query legacy (v0) notes by address. */ -export interface QueryV0BalanceParams { - /** gRPC endpoint to query notes from. */ - grpcEndpoint: string; - /** - * Legacy base58 address (bare pubkey). - * Preferred field. - */ - sourceAddress?: string; - /** - * Back-compat alias. Historically named as PKH, but routed to getBalanceByAddress. - * If sourceAddress is provided, this field is ignored. - */ - sourcePkh?: string; } -/** Result of querying v0 balance via first-name lookup. */ +/** Result of querying v0 balance. */ export interface QueryV0BalanceResult { - /** Optional first-name digest (not required when querying by address). */ - firstName?: string; - /** Raw balance payload from gRPC. */ balance: PbCom2Balance; - /** Parsed v0 notes found in the balance response. */ v0Notes: NoteV0[]; - /** Sum of v0 note assets in nicks (string for bigint-safe transport). */ totalNicks: Nicks; } -/** Input required to derive v0 address and immediately query legacy notes. */ -export interface QueryV0BalanceFromMnemonicParams extends DeriveV0AddressParams { - /** gRPC endpoint to query notes from. */ - grpcEndpoint: string; -} - -/** Result of mnemonic-based v0 note discovery. */ -export interface QueryV0BalanceFromMnemonicResult extends QueryV0BalanceResult, DerivedV0Address {} - -/** Inputs to build a migration transaction directly from mnemonic + destination. */ -export interface BuildV0MigrationFromMnemonicParams - extends QueryV0BalanceFromMnemonicParams, - Omit {} - -/** Output of mnemonic-based migration transaction building. */ -export interface BuildV0MigrationFromMnemonicResult extends BuildV0MigrationTransactionResult { - /** Discovery data used to assemble the transaction. */ - discovery: QueryV0BalanceFromMnemonicResult; -} - -/** Input for building a migration transaction from v0 notes to a v1 PKH address. */ -export interface BuildV0MigrationTransactionParams { - /** Legacy notes to migrate (typically from queryV0BalanceForPkh). */ - v0Notes: NoteV0[]; - /** Destination v1 PKH (usually the extension wallet PKH). */ - targetV1Pkh: string; - /** Fee-per-word used by TxBuilder (WASM Nicks = string, e.g. "32768"). */ - feePerWord?: Nicks; - /** Whether to include lock metadata in refund/migration seeds. */ - includeLockData?: boolean; -} +/** Result of mnemonic-based v0 discovery (derived address + balance). */ +export interface QueryV0BalanceFromMnemonicResult + extends QueryV0BalanceResult, + DerivedV0Address {} -/** Result of building a v0 -> v1 migration transaction. */ +/** Result of building a migration transaction. */ export interface BuildV0MigrationTransactionResult { - /** Built transaction object returned by iris-wasm TxBuilder. */ transaction: NockchainTx; - /** Transaction id (normalized as a string). */ txId: string; - /** Calculated fee in nicks (WASM Nicks = string). */ fee: Nicks; - /** Payload compatible with provider.signRawTx(...) */ signRawTxPayload: { rawTx: RawTx; notes: NoteV0[]; spendConditions: SpendCondition[]; }; } + +/** Result of mnemonic-based migration build. */ +export interface BuildV0MigrationFromMnemonicResult + extends BuildV0MigrationTransactionResult { + discovery: QueryV0BalanceFromMnemonicResult; +} diff --git a/src/migration.ts b/src/migration.ts index dc61522..d99607f 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -1,17 +1,13 @@ import type { - BuildV0MigrationFromMnemonicParams, BuildV0MigrationFromMnemonicResult, - BuildV0MigrationTransactionParams, BuildV0MigrationTransactionResult, - DeriveV0AddressParams, DerivedV0Address, - QueryV0BalanceParams, - QueryV0BalanceFromMnemonicParams, QueryV0BalanceFromMnemonicResult, QueryV0BalanceResult, } from './migration-types.js'; import type { Note, + Nicks, NoteV0, PbCom2BalanceEntry, RawTxV1, @@ -34,10 +30,6 @@ function isNoteV0(note: Note): note is NoteV0 { return 'inner' in note && 'sig' in note && 'source' in note; } -function toHex(bytes: Uint8Array): string { - return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); -} - function sumNicks(notes: NoteV0[]): string { const total = notes.reduce((acc, note) => acc + BigInt(note.assets), 0n); return total.toString(); @@ -47,17 +39,18 @@ function sumNicks(notes: NoteV0[]): string { * Derive legacy v0 address metadata from mnemonic. * * v0 discovery queries use the base58-encoded bare public key ("sourceAddress"). - * We also expose the hashed PKH digest for callers that need lock condition metadata. */ -export function deriveV0AddressFromMnemonic(params: DeriveV0AddressParams): DerivedV0Address { - const master = wasm.deriveMasterKeyFromMnemonic(params.mnemonic, params.passphrase ?? ''); - const key = params.childIndex === undefined ? master : master.deriveChild(params.childIndex); +export function deriveV0AddressFromMnemonic( + mnemonic: string, + passphrase?: string, + childIndex?: number +): DerivedV0Address { + const master = wasm.deriveMasterKeyFromMnemonic(mnemonic, passphrase ?? ''); + const key = childIndex === undefined ? master : master.deriveChild(childIndex); const publicKey = Uint8Array.from(key.publicKey); return { sourceAddress: base58.encode(publicKey), - sourcePkh: wasm.hashPublicKey(publicKey), - publicKeyHex: toHex(publicKey), }; } @@ -66,15 +59,15 @@ export function deriveV0AddressFromMnemonic(params: DeriveV0AddressParams): Deri * Caller must have initialized WASM (e.g. await wasm.default()) before using. */ export async function queryV0BalanceForAddress( - params: QueryV0BalanceParams + grpcEndpoint: string, + address: string ): Promise { - const sourceAddress = params.sourceAddress ?? params.sourcePkh; - if (!sourceAddress) { - throw new Error('sourceAddress is required'); + if (!address) { + throw new Error('address is required'); } - const grpcClient = new wasm.GrpcClient(params.grpcEndpoint); - const balance = await grpcClient.getBalanceByAddress(sourceAddress); + const grpcClient = new wasm.GrpcClient(grpcEndpoint); + const balance = await grpcClient.getBalanceByAddress(address); const v0Notes: NoteV0[] = []; const entries = balance.notes ?? []; @@ -95,27 +88,17 @@ export async function queryV0BalanceForAddress( }; } -/** - * Back-compat alias kept for older callers. - * Despite the name, this resolves to address-based lookup via getBalanceByAddress. - */ -export async function queryV0BalanceForPkh( - params: QueryV0BalanceParams -): Promise { - return queryV0BalanceForAddress(params); -} - /** * Derive v0 discovery address from mnemonic and query legacy notes in one step. */ export async function queryV0BalanceFromMnemonic( - params: QueryV0BalanceFromMnemonicParams + mnemonic: string, + grpcEndpoint: string, + passphrase?: string, + childIndex?: number ): Promise { - const derived = deriveV0AddressFromMnemonic(params); - const queried = await queryV0BalanceForAddress({ - grpcEndpoint: params.grpcEndpoint, - sourceAddress: derived.sourceAddress, - }); + const derived = deriveV0AddressFromMnemonic(mnemonic, passphrase, childIndex); + const queried = await queryV0BalanceForAddress(grpcEndpoint, derived.sourceAddress); return { ...derived, @@ -123,38 +106,46 @@ export async function queryV0BalanceFromMnemonic( }; } +const DEFAULT_TX_ENGINE_SETTINGS: TxEngineSettings = { + tx_engine_version: 1, + tx_engine_patch: 0, + min_fee: '256', + cost_per_word: '32768', + witness_word_div: 1, +}; + /** * Build a transaction that migrates v0 notes into a v1 PKH lock. * Caller must have initialized WASM (e.g. await wasm.default()) before using. */ export async function buildV0MigrationTransaction( - params: BuildV0MigrationTransactionParams + v0Notes: NoteV0[], + targetV1Pkh: string, + feePerWord?: Nicks, + includeLockData?: boolean, + settings?: Partial ): Promise { - if (!params.v0Notes.length) { + if (!v0Notes.length) { throw new Error('No v0 notes provided for migration'); } - const includeLockData = !!params.includeLockData; - const costPerWord = params.feePerWord ?? '32768'; - - const targetSpendCondition = buildSinglePkhSpendCondition(params.targetV1Pkh); - const settings: TxEngineSettings = { - tx_engine_version: 1, - tx_engine_patch: 0, - min_fee: '256', - cost_per_word: costPerWord, - witness_word_div: 1, + const includeLockDataVal = !!includeLockData; + const txSettings: TxEngineSettings = { + ...DEFAULT_TX_ENGINE_SETTINGS, + ...settings, + cost_per_word: feePerWord ?? settings?.cost_per_word ?? DEFAULT_TX_ENGINE_SETTINGS.cost_per_word, }; - const builder = new wasm.TxBuilder(settings); + const targetSpendCondition = buildSinglePkhSpendCondition(targetV1Pkh); + const builder = new wasm.TxBuilder(txSettings); - for (const note of params.v0Notes) { + for (const note of v0Notes) { const spendBuilder = new wasm.SpendBuilder(note, null, targetSpendCondition); // Use refund path to migrate full note value into target lock. - spendBuilder.computeRefund(includeLockData); + spendBuilder.computeRefund(includeLockDataVal); builder.spend(spendBuilder); } - builder.recalcAndSetFee(includeLockData); + builder.recalcAndSetFee(includeLockDataVal); const feeResult = builder.calcFee(); const transaction = builder.build(); const txNotes = builder.allNotes() as TxNotes; @@ -178,18 +169,26 @@ export async function buildV0MigrationTransaction( } /** - * Derive v0 address, query legacy notes, and build migration transaction. + * Derive v0 address, query legacy notes, and build migration transaction in one step. */ export async function buildV0MigrationFromMnemonic( - params: BuildV0MigrationFromMnemonicParams + mnemonic: string, + grpcEndpoint: string, + targetV1Pkh: string, + passphrase?: string, + childIndex?: number, + feePerWord?: Nicks, + includeLockData?: boolean, + settings?: Partial ): Promise { - const discovery = await queryV0BalanceFromMnemonic(params); - const built = await buildV0MigrationTransaction({ - v0Notes: discovery.v0Notes, - targetV1Pkh: params.targetV1Pkh, - feePerWord: params.feePerWord, - includeLockData: params.includeLockData, - }); + const discovery = await queryV0BalanceFromMnemonic(mnemonic, grpcEndpoint, passphrase, childIndex); + const built = await buildV0MigrationTransaction( + discovery.v0Notes, + targetV1Pkh, + feePerWord, + includeLockData, + settings + ); return { ...built, @@ -198,14 +197,9 @@ export async function buildV0MigrationFromMnemonic( } export type { - BuildV0MigrationFromMnemonicParams, BuildV0MigrationFromMnemonicResult, - BuildV0MigrationTransactionParams, BuildV0MigrationTransactionResult, - DeriveV0AddressParams, DerivedV0Address, - QueryV0BalanceParams, - QueryV0BalanceFromMnemonicParams, QueryV0BalanceFromMnemonicResult, QueryV0BalanceResult, } from './migration-types.js'; From 8d0c8ca2c0a224fdb33b66259227fe5bc33e94b8 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:29:42 -0500 Subject: [PATCH 05/11] temporary commit for testing: use the smallest note for migration --- src/migration-types.ts | 9 +++ src/migration.ts | 162 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 161 insertions(+), 10 deletions(-) diff --git a/src/migration-types.ts b/src/migration-types.ts index 1aa3694..e2cdc9f 100644 --- a/src/migration-types.ts +++ b/src/migration-types.ts @@ -43,6 +43,15 @@ export interface BuildV0MigrationTransactionResult { }; } +/** Result of single-note migration (matches extension logic). */ +export interface BuildV0MigrationSingleNoteResult extends BuildV0MigrationTransactionResult { + migratedNicks: Nicks; + migratedNock: number; + selectedNoteNicks: Nicks; + selectedNoteNock: number; + feeNock: number; +} + /** Result of mnemonic-based migration build. */ export interface BuildV0MigrationFromMnemonicResult extends BuildV0MigrationTransactionResult { diff --git a/src/migration.ts b/src/migration.ts index d99607f..4fd41bc 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -1,6 +1,7 @@ import type { BuildV0MigrationFromMnemonicResult, BuildV0MigrationTransactionResult, + BuildV0MigrationSingleNoteResult, DerivedV0Address, QueryV0BalanceFromMnemonicResult, QueryV0BalanceResult, @@ -35,6 +36,8 @@ function sumNicks(notes: NoteV0[]): string { return total.toString(); } +const NOCK_TO_NICKS = 65_536; + /** * Derive legacy v0 address metadata from mnemonic. * @@ -48,9 +51,10 @@ export function deriveV0AddressFromMnemonic( const master = wasm.deriveMasterKeyFromMnemonic(mnemonic, passphrase ?? ''); const key = childIndex === undefined ? master : master.deriveChild(childIndex); const publicKey = Uint8Array.from(key.publicKey); + const sourceAddress = base58.encode(publicKey); return { - sourceAddress: base58.encode(publicKey), + sourceAddress, }; } @@ -106,29 +110,48 @@ export async function queryV0BalanceFromMnemonic( }; } +/** Patch 1 (Bythos) - fee auto-calculated via recalcAndSetFee */ const DEFAULT_TX_ENGINE_SETTINGS: TxEngineSettings = { tx_engine_version: 1, - tx_engine_patch: 0, + tx_engine_patch: 1, min_fee: '256', - cost_per_word: '32768', - witness_word_div: 1, + cost_per_word: '16384', // 1 << 14 + witness_word_div: 4, }; /** * Build a transaction that migrates v0 notes into a v1 PKH lock. * Caller must have initialized WASM (e.g. await wasm.default()) before using. + * + * @param options.singleNoteOnly - [TEMPORARY] When true, uses single-note logic for testing. + * @param options.debug - [TEMPORARY] When true, logs the built result to console. */ export async function buildV0MigrationTransaction( v0Notes: NoteV0[], targetV1Pkh: string, feePerWord?: Nicks, includeLockData?: boolean, - settings?: Partial -): Promise { + settings?: Partial, + options?: { singleNoteOnly?: boolean; debug?: boolean } +): Promise { if (!v0Notes.length) { throw new Error('No v0 notes provided for migration'); } + const singleNoteOnly = options?.singleNoteOnly ?? false; + const debug = options?.debug ?? false; + + if (singleNoteOnly) { + const result = await buildV0MigrationTransactionSingleNote( + v0Notes, + targetV1Pkh, + feePerWord, + settings, + debug + ); + return result; + } + const includeLockDataVal = !!includeLockData; const txSettings: TxEngineSettings = { ...DEFAULT_TX_ENGINE_SETTINGS, @@ -156,7 +179,7 @@ export async function buildV0MigrationTransaction( spends: transaction.spends, }; - return { + const result: BuildV0MigrationTransactionResult = { transaction, txId, fee: feeResult, @@ -166,10 +189,88 @@ export async function buildV0MigrationTransaction( spendConditions: txNotes.spend_conditions, }, }; + + if (debug) { + console.log('[SDK Migration] buildV0MigrationTransaction (full)', result); + } + + return result; +} + +/** + * Single-note migration (same logic as regular path, but one note). + * Picks any of the smallest notes (there may be multiple with the same size). + */ +async function buildV0MigrationTransactionSingleNote( + v0Notes: NoteV0[], + targetV1Pkh: string, + feePerWord?: Nicks, + settings?: Partial, + debug?: boolean +): Promise { + const targetSpendCondition = buildSinglePkhSpendCondition(targetV1Pkh); + const txSettings: TxEngineSettings = { + ...DEFAULT_TX_ENGINE_SETTINGS, + ...settings, + cost_per_word: feePerWord ?? settings?.cost_per_word ?? DEFAULT_TX_ENGINE_SETTINGS.cost_per_word, + }; + const builder = new wasm.TxBuilder(txSettings); + + const candidates: Array<{ note: NoteV0; assets: bigint }> = v0Notes.map(note => ({ + note, + assets: BigInt(note.assets), + })); + + if (!candidates.length) { + throw new Error('No v0 notes to migrate.'); + } + + const minAssets = candidates.reduce((min, c) => (c.assets < min ? c.assets : min), candidates[0].assets); + const selected = candidates.find(c => c.assets === minAssets)!; + + const spendBuilder = new wasm.SpendBuilder(selected.note, null, targetSpendCondition); + spendBuilder.computeRefund(false); + builder.spend(spendBuilder); + + builder.recalcAndSetFee(false); + const feeNicks = builder.calcFee(); + const transaction = builder.build(); + const txNotes = builder.allNotes() as TxNotes; + const rawTx: RawTxV1 = { + version: 1, + id: transaction.id, + spends: transaction.spends, + }; + + const feeNicksBigInt = BigInt(feeNicks); + const result: BuildV0MigrationSingleNoteResult = { + transaction, + txId: transaction.id, + fee: feeNicks, + migratedNicks: selected.assets.toString(), + migratedNock: Number(selected.assets) / NOCK_TO_NICKS, + selectedNoteNicks: selected.assets.toString(), + selectedNoteNock: Number(selected.assets) / NOCK_TO_NICKS, + feeNock: Number(feeNicksBigInt) / NOCK_TO_NICKS, + signRawTxPayload: { + rawTx, + notes: txNotes.notes.filter((note): note is NoteV0 => isNoteV0(note)), + spendConditions: txNotes.spend_conditions, + }, + }; + + if (debug) { + console.log('[SDK Migration] buildV0MigrationTransactionSingleNote', result); + } + + return result; } /** * Derive v0 address, query legacy notes, and build migration transaction in one step. + * + * @param options.singleNoteOnly - [TEMPORARY] When true, migrates 200 NOCK from one note. + * @param options.debug - [TEMPORARY] When true, logs the built result to console. */ export async function buildV0MigrationFromMnemonic( mnemonic: string, @@ -179,26 +280,67 @@ export async function buildV0MigrationFromMnemonic( childIndex?: number, feePerWord?: Nicks, includeLockData?: boolean, - settings?: Partial + settings?: Partial, + options?: { singleNoteOnly?: boolean; debug?: boolean } ): Promise { const discovery = await queryV0BalanceFromMnemonic(mnemonic, grpcEndpoint, passphrase, childIndex); + const buildOptions = options?.singleNoteOnly + ? { singleNoteOnly: true as const, debug: options?.debug } + : { debug: options?.debug }; const built = await buildV0MigrationTransaction( discovery.v0Notes, targetV1Pkh, feePerWord, includeLockData, - settings + settings, + buildOptions ); - return { + const result = { ...built, discovery, }; + + if (options?.debug) { + console.log('[SDK Migration] buildV0MigrationFromMnemonic', result); + } + + return result; +} + +/** + * Build migration from protobuf notes (matches extension API). + * Caller must have initialized WASM before using. + */ +export async function buildV0MigrationTransactionFromNotes( + v0NotesProtobuf: unknown[], + targetV1Pkh: string, + feePerWord: Nicks = '32768', + options?: { debug?: boolean } +): Promise { + const v0Notes: NoteV0[] = []; + for (const notePb of v0NotesProtobuf) { + const parsed = wasm.note_from_protobuf(notePb as Parameters[0]); + if (isNoteV0(parsed)) { + v0Notes.push(parsed); + } + } + + const result = await buildV0MigrationTransactionSingleNote( + v0Notes, + targetV1Pkh, + feePerWord, + undefined, + options?.debug ?? false + ); + + return result; } export type { BuildV0MigrationFromMnemonicResult, BuildV0MigrationTransactionResult, + BuildV0MigrationSingleNoteResult, DerivedV0Address, QueryV0BalanceFromMnemonicResult, QueryV0BalanceResult, From 079f53538bcbd6fb8b5539eb84def8bbae56cd10 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:16:46 -0500 Subject: [PATCH 06/11] add spend condition to the rebuilt transaction to satisfy the API --- src/migration.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/migration.ts b/src/migration.ts index 4fd41bc..321d85d 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -179,14 +179,19 @@ export async function buildV0MigrationTransaction( spends: transaction.spends, }; + const inputNotes = txNotes.notes.filter((note): note is NoteV0 => isNoteV0(note)); + const spendConditions = + txNotes.spend_conditions.length === inputNotes.length + ? txNotes.spend_conditions + : inputNotes.map(() => targetSpendCondition); const result: BuildV0MigrationTransactionResult = { transaction, txId, fee: feeResult, signRawTxPayload: { rawTx, - notes: txNotes.notes.filter((note): note is NoteV0 => isNoteV0(note)), - spendConditions: txNotes.spend_conditions, + notes: inputNotes, + spendConditions, }, }; @@ -243,6 +248,11 @@ async function buildV0MigrationTransactionSingleNote( }; const feeNicksBigInt = BigInt(feeNicks); + const inputNotes = txNotes.notes.filter((note): note is NoteV0 => isNoteV0(note)); + const spendConditions = + txNotes.spend_conditions.length === inputNotes.length + ? txNotes.spend_conditions + : inputNotes.map(() => targetSpendCondition); const result: BuildV0MigrationSingleNoteResult = { transaction, txId: transaction.id, @@ -254,8 +264,8 @@ async function buildV0MigrationTransactionSingleNote( feeNock: Number(feeNicksBigInt) / NOCK_TO_NICKS, signRawTxPayload: { rawTx, - notes: txNotes.notes.filter((note): note is NoteV0 => isNoteV0(note)), - spendConditions: txNotes.spend_conditions, + notes: inputNotes, + spendConditions, }, }; From dc5101cbf41e407ca335718fc00d17d586a3ac44 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:43:33 -0500 Subject: [PATCH 07/11] fix RPC --- src/migration.ts | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/src/migration.ts b/src/migration.ts index 321d85d..452ba71 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -38,6 +38,12 @@ function sumNicks(notes: NoteV0[]): string { const NOCK_TO_NICKS = 65_536; +function normalizeGrpcEndpoint(endpoint: string): string { + const trimmed = endpoint?.trim() || ''; + if (!trimmed) return trimmed; + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +} + /** * Derive legacy v0 address metadata from mnemonic. * @@ -49,13 +55,18 @@ export function deriveV0AddressFromMnemonic( childIndex?: number ): DerivedV0Address { const master = wasm.deriveMasterKeyFromMnemonic(mnemonic, passphrase ?? ''); - const key = childIndex === undefined ? master : master.deriveChild(childIndex); - const publicKey = Uint8Array.from(key.publicKey); - const sourceAddress = base58.encode(publicKey); - - return { - sourceAddress, - }; + try { + const key = childIndex === undefined ? master : master.deriveChild(childIndex); + try { + const publicKey = Uint8Array.from(key.publicKey); + const sourceAddress = base58.encode(publicKey); + return { sourceAddress }; + } finally { + if (key !== master) key.free(); + } + } finally { + master.free(); + } } /** @@ -70,7 +81,8 @@ export async function queryV0BalanceForAddress( throw new Error('address is required'); } - const grpcClient = new wasm.GrpcClient(grpcEndpoint); + const normalizedEndpoint = normalizeGrpcEndpoint(grpcEndpoint); + const grpcClient = new wasm.GrpcClient(normalizedEndpoint); const balance = await grpcClient.getBalanceByAddress(address); const v0Notes: NoteV0[] = []; @@ -94,6 +106,8 @@ export async function queryV0BalanceForAddress( /** * Derive v0 discovery address from mnemonic and query legacy notes in one step. + * Tries master key first; if no Legacy notes found, retries with child index 0 + * (some v0 wallets used child derivation). */ export async function queryV0BalanceFromMnemonic( mnemonic: string, @@ -104,10 +118,19 @@ export async function queryV0BalanceFromMnemonic( const derived = deriveV0AddressFromMnemonic(mnemonic, passphrase, childIndex); const queried = await queryV0BalanceForAddress(grpcEndpoint, derived.sourceAddress); - return { - ...derived, - ...queried, - }; + if (queried.v0Notes.length > 0) { + return { ...derived, ...queried }; + } + + if (childIndex === undefined) { + const derivedChild0 = deriveV0AddressFromMnemonic(mnemonic, passphrase, 0); + const queriedChild0 = await queryV0BalanceForAddress(grpcEndpoint, derivedChild0.sourceAddress); + if (queriedChild0.v0Notes.length > 0) { + return { ...derivedChild0, ...queriedChild0 }; + } + } + + return { ...derived, ...queried }; } /** Patch 1 (Bythos) - fee auto-calculated via recalcAndSetFee */ From c0101ac63a967f73d63d22a5e37afecdf2afe57c Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:31:35 -0400 Subject: [PATCH 08/11] Remove vendor/iris-wasm from package, add vendor/ to gitignore Made-with: Cursor --- .gitignore | 1 + package.json | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) 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/package.json b/package.json index 812c6a7..46b3e6a 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,7 @@ }, "files": [ "dist", - "README.md", - "vendor/iris-wasm" + "README.md" ], "scripts": { "build": "tsc", From 2b989a89d9b7ba8a06b6ba0077e786fbcae0e948 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:26:28 -0400 Subject: [PATCH 09/11] update iris-wasm API calls --- src/bridge.ts | 14 +++++++------- src/migration.ts | 30 ++++++++++++------------------ 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/bridge.ts b/src/bridge.ts index 827de22..fce7943 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -143,11 +143,11 @@ export async function buildBridgeTransaction( const bridgeNounJs = buildBridgeNoun(params.destinationAddress, config); const noteData: NoteData = [[config.noteDataKey, bridgeNounJs as Noun]]; - const bridgeSpendCondition: SpendCondition = [ - { Pkh: { m: config.threshold, hashes: config.addresses } }, - ]; - const bridgeLockRoot: LockRoot = { Lock: bridgeSpendCondition }; - const refundLock: SpendCondition = [{ Pkh: { m: 1, hashes: [params.refundPkh] } }]; + const bridgePkh = wasm.pkhNew(BigInt(config.threshold), config.addresses); + const bridgeSpendCondition: SpendCondition = wasm.spendConditionNewPkh(bridgePkh); + const bridgeLockRoot: LockRoot = bridgeSpendCondition; + const refundPkhObj = wasm.pkhSingle(params.refundPkh); + const refundLock: SpendCondition = wasm.spendConditionNewPkh(refundPkhObj); const costPerWord = params.feeOverride ?? config.feePerWord; const builder = new wasm.TxBuilder({ @@ -162,9 +162,9 @@ export async function buildBridgeTransaction( const note = params.inputNotes[i]; const spendCondition = params.spendConditions[i]; - const spendBuilder = new wasm.SpendBuilder(note, spendCondition, refundLock); + const spendBuilder = new wasm.SpendBuilder(note, spendCondition, null, refundLock); - const parentHash = wasm.note_hash(note); + const parentHash = wasm.noteHash(note); const seed: SeedV1 = { output_source: null, lock_root: bridgeLockRoot, diff --git a/src/migration.ts b/src/migration.ts index 452ba71..76c48eb 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -14,13 +14,13 @@ import type { RawTxV1, SpendCondition, TxEngineSettings, - TxNotes, } from '@nockbox/iris-wasm/iris_wasm.js'; import { base58 } from '@scure/base'; import * as wasm from './wasm.js'; function buildSinglePkhSpendCondition(pkh: string): SpendCondition { - return [{ Pkh: { m: 1, hashes: [pkh] } }]; + const pkhObj = wasm.pkhSingle(pkh); + return wasm.spendConditionNewPkh(pkhObj); } function isLegacyEntry(entry: PbCom2BalanceEntry): boolean { @@ -91,7 +91,7 @@ export async function queryV0BalanceForAddress( if (!isLegacyEntry(entry) || !entry.note) { continue; } - const parsed = wasm.note_from_protobuf(entry.note); + const parsed = wasm.noteFromProtobuf(entry.note); if (isNoteV0(parsed)) { v0Notes.push(parsed); } @@ -185,7 +185,7 @@ export async function buildV0MigrationTransaction( const builder = new wasm.TxBuilder(txSettings); for (const note of v0Notes) { - const spendBuilder = new wasm.SpendBuilder(note, null, targetSpendCondition); + const spendBuilder = new wasm.SpendBuilder(note, targetSpendCondition, null, null); // Use refund path to migrate full note value into target lock. spendBuilder.computeRefund(includeLockDataVal); builder.spend(spendBuilder); @@ -194,7 +194,7 @@ export async function buildV0MigrationTransaction( builder.recalcAndSetFee(includeLockDataVal); const feeResult = builder.calcFee(); const transaction = builder.build(); - const txNotes = builder.allNotes() as TxNotes; + const allNotes = builder.allNotes(); const txId = transaction.id; const rawTx: RawTxV1 = { version: 1, @@ -202,11 +202,8 @@ export async function buildV0MigrationTransaction( spends: transaction.spends, }; - const inputNotes = txNotes.notes.filter((note): note is NoteV0 => isNoteV0(note)); - const spendConditions = - txNotes.spend_conditions.length === inputNotes.length - ? txNotes.spend_conditions - : inputNotes.map(() => targetSpendCondition); + const inputNotes = allNotes.filter((note): note is NoteV0 => isNoteV0(note)); + const spendConditions = inputNotes.map(() => targetSpendCondition); const result: BuildV0MigrationTransactionResult = { transaction, txId, @@ -256,14 +253,14 @@ async function buildV0MigrationTransactionSingleNote( const minAssets = candidates.reduce((min, c) => (c.assets < min ? c.assets : min), candidates[0].assets); const selected = candidates.find(c => c.assets === minAssets)!; - const spendBuilder = new wasm.SpendBuilder(selected.note, null, targetSpendCondition); + const spendBuilder = new wasm.SpendBuilder(selected.note, targetSpendCondition, null, null); spendBuilder.computeRefund(false); builder.spend(spendBuilder); builder.recalcAndSetFee(false); const feeNicks = builder.calcFee(); const transaction = builder.build(); - const txNotes = builder.allNotes() as TxNotes; + const allNotes = builder.allNotes(); const rawTx: RawTxV1 = { version: 1, id: transaction.id, @@ -271,11 +268,8 @@ async function buildV0MigrationTransactionSingleNote( }; const feeNicksBigInt = BigInt(feeNicks); - const inputNotes = txNotes.notes.filter((note): note is NoteV0 => isNoteV0(note)); - const spendConditions = - txNotes.spend_conditions.length === inputNotes.length - ? txNotes.spend_conditions - : inputNotes.map(() => targetSpendCondition); + const inputNotes = allNotes.filter((note): note is NoteV0 => isNoteV0(note)); + const spendConditions = inputNotes.map(() => targetSpendCondition); const result: BuildV0MigrationSingleNoteResult = { transaction, txId: transaction.id, @@ -353,7 +347,7 @@ export async function buildV0MigrationTransactionFromNotes( ): Promise { const v0Notes: NoteV0[] = []; for (const notePb of v0NotesProtobuf) { - const parsed = wasm.note_from_protobuf(notePb as Parameters[0]); + const parsed = wasm.noteFromProtobuf(notePb as Parameters[0]); if (isNoteV0(parsed)) { v0Notes.push(parsed); } From 2bb34b47d7bb462cc88376e0852a88bd3f180d95 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:26:18 -0400 Subject: [PATCH 10/11] clean up migration logic --- src/migration-types.ts | 63 +++---- src/migration.ts | 406 ++++++++++++----------------------------- 2 files changed, 143 insertions(+), 326 deletions(-) diff --git a/src/migration-types.ts b/src/migration-types.ts index e2cdc9f..456503b 100644 --- a/src/migration-types.ts +++ b/src/migration-types.ts @@ -1,9 +1,9 @@ /** * Types for querying v0 balance and building v0 -> v1 migration transactions. - * Aligned with @nockbox/iris-wasm (Nicks, NockchainTx, NoteV0, RawTx, SpendCondition). + * Aligned with @nockbox/iris-wasm (Nicks, NoteV0, RawTx, SpendCondition). */ import type { - NockchainTx, + LockRoot, Nicks, NoteV0, PbCom2Balance, @@ -13,47 +13,38 @@ import type { export type { Nicks }; -/** Legacy address metadata derived from mnemonic. */ -export interface DerivedV0Address { - /** Base58 bare public key (legacy address form). */ - sourceAddress: string; -} +/** Base58 bare public key derived from mnemonic. */ +export type DerivedV0Address = string; -/** Result of querying v0 balance. */ -export interface QueryV0BalanceResult { +/** 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; } -/** Result of mnemonic-based v0 discovery (derived address + balance). */ -export interface QueryV0BalanceFromMnemonicResult - extends QueryV0BalanceResult, - DerivedV0Address {} - -/** Result of building a migration transaction. */ -export interface BuildV0MigrationTransactionResult { - transaction: NockchainTx; - txId: string; - fee: Nicks; - signRawTxPayload: { +/** buildV0MigrationTx result: balance fields always present; tx fields when target provided and 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; + signRawTxPayload?: { rawTx: RawTx; notes: NoteV0[]; - spendConditions: SpendCondition[]; + spendConditions: (SpendCondition | null)[]; + refundLock: LockRoot; }; -} - -/** Result of single-note migration (matches extension logic). */ -export interface BuildV0MigrationSingleNoteResult extends BuildV0MigrationTransactionResult { - migratedNicks: Nicks; - migratedNock: number; - selectedNoteNicks: Nicks; - selectedNoteNock: number; - feeNock: number; -} - -/** Result of mnemonic-based migration build. */ -export interface BuildV0MigrationFromMnemonicResult - extends BuildV0MigrationTransactionResult { - discovery: QueryV0BalanceFromMnemonicResult; + migratedNicks?: Nicks; + migratedNock?: number; } diff --git a/src/migration.ts b/src/migration.ts index 76c48eb..2bd9cac 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -1,10 +1,7 @@ import type { - BuildV0MigrationFromMnemonicResult, - BuildV0MigrationTransactionResult, - BuildV0MigrationSingleNoteResult, + BuildV0MigrationTxResult, DerivedV0Address, - QueryV0BalanceFromMnemonicResult, - QueryV0BalanceResult, + V0BalanceResult, } from './migration-types.js'; import type { Note, @@ -38,52 +35,30 @@ function sumNicks(notes: NoteV0[]): string { const NOCK_TO_NICKS = 65_536; -function normalizeGrpcEndpoint(endpoint: string): string { - const trimmed = endpoint?.trim() || ''; - if (!trimmed) return trimmed; - return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; -} - /** - * Derive legacy v0 address metadata from mnemonic. - * - * v0 discovery queries use the base58-encoded bare public key ("sourceAddress"). + * Derive legacy v0 address (base58 bare public key) from mnemonic. */ -export function deriveV0AddressFromMnemonic( - mnemonic: string, - passphrase?: string, - childIndex?: number -): DerivedV0Address { - const master = wasm.deriveMasterKeyFromMnemonic(mnemonic, passphrase ?? ''); +export function deriveV0AddressFromMnemonic(mnemonic: string): DerivedV0Address { + const master = wasm.deriveMasterKeyFromMnemonic(mnemonic); try { - const key = childIndex === undefined ? master : master.deriveChild(childIndex); - try { - const publicKey = Uint8Array.from(key.publicKey); - const sourceAddress = base58.encode(publicKey); - return { sourceAddress }; - } finally { - if (key !== master) key.free(); - } + const publicKey = Uint8Array.from(master.publicKey); + return base58.encode(publicKey); } finally { master.free(); } } /** - * Query address balance and return only v0 (Legacy) notes. + * Query v0 (Legacy) balance for a mnemonic. Discovery only; does not build a transaction. * Caller must have initialized WASM (e.g. await wasm.default()) before using. */ -export async function queryV0BalanceForAddress( - grpcEndpoint: string, - address: string -): Promise { - if (!address) { - throw new Error('address is required'); - } - - const normalizedEndpoint = normalizeGrpcEndpoint(grpcEndpoint); - const grpcClient = new wasm.GrpcClient(normalizedEndpoint); - const balance = await grpcClient.getBalanceByAddress(address); +export async function queryV0Balance( + mnemonic: string, + grpcEndpoint: string +): Promise { + const sourceAddress = deriveV0AddressFromMnemonic(mnemonic); + const grpcClient = new wasm.GrpcClient(grpcEndpoint); + const balance = await grpcClient.getBalanceByAddress(sourceAddress); const v0Notes: NoteV0[] = []; const entries = balance.notes ?? []; @@ -97,278 +72,129 @@ export async function queryV0BalanceForAddress( } } + 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: sumNicks(v0Notes), + totalNicks, + totalNock, + smallestNoteNock, + rawNotesFromRpc: entries.length, }; } -/** - * Derive v0 discovery address from mnemonic and query legacy notes in one step. - * Tries master key first; if no Legacy notes found, retries with child index 0 - * (some v0 wallets used child derivation). - */ -export async function queryV0BalanceFromMnemonic( - mnemonic: string, - grpcEndpoint: string, - passphrase?: string, - childIndex?: number -): Promise { - const derived = deriveV0AddressFromMnemonic(mnemonic, passphrase, childIndex); - const queried = await queryV0BalanceForAddress(grpcEndpoint, derived.sourceAddress); - - if (queried.v0Notes.length > 0) { - return { ...derived, ...queried }; - } - - if (childIndex === undefined) { - const derivedChild0 = deriveV0AddressFromMnemonic(mnemonic, passphrase, 0); - const queriedChild0 = await queryV0BalanceForAddress(grpcEndpoint, derivedChild0.sourceAddress); - if (queriedChild0.v0Notes.length > 0) { - return { ...derivedChild0, ...queriedChild0 }; - } - } - - return { ...derived, ...queried }; +function defaultTxEngineSettings(): TxEngineSettings { + return wasm.txEngineSettingsV1BythosDefault(); } -/** Patch 1 (Bythos) - fee auto-calculated via recalcAndSetFee */ -const DEFAULT_TX_ENGINE_SETTINGS: TxEngineSettings = { - tx_engine_version: 1, - tx_engine_patch: 1, - min_fee: '256', - cost_per_word: '16384', // 1 << 14 - witness_word_div: 4, -}; - /** - * Build a transaction that migrates v0 notes into a v1 PKH lock. + * Fetch v0 balance and optionally build migration tx to a v1 PKH lock. * Caller must have initialized WASM (e.g. await wasm.default()) before using. * - * @param options.singleNoteOnly - [TEMPORARY] When true, uses single-note logic for testing. - * @param options.debug - [TEMPORARY] When true, logs the built result to console. + * @param targetV1Pkh - When provided, builds the migration tx. Omit for balance only. + * @param options.debug - When true, logs the built result to console. */ -export async function buildV0MigrationTransaction( - v0Notes: NoteV0[], - targetV1Pkh: string, - feePerWord?: Nicks, - includeLockData?: boolean, - settings?: Partial, - options?: { singleNoteOnly?: boolean; debug?: boolean } -): Promise { - if (!v0Notes.length) { - throw new Error('No v0 notes provided for migration'); - } - - const singleNoteOnly = options?.singleNoteOnly ?? false; - const debug = options?.debug ?? false; - - if (singleNoteOnly) { - const result = await buildV0MigrationTransactionSingleNote( - v0Notes, - targetV1Pkh, - feePerWord, - settings, - debug - ); - return result; - } - - const includeLockDataVal = !!includeLockData; - const txSettings: TxEngineSettings = { - ...DEFAULT_TX_ENGINE_SETTINGS, - ...settings, - cost_per_word: feePerWord ?? settings?.cost_per_word ?? DEFAULT_TX_ENGINE_SETTINGS.cost_per_word, - }; - const targetSpendCondition = buildSinglePkhSpendCondition(targetV1Pkh); - const builder = new wasm.TxBuilder(txSettings); - - for (const note of v0Notes) { - const spendBuilder = new wasm.SpendBuilder(note, targetSpendCondition, null, null); - // Use refund path to migrate full note value into target lock. - spendBuilder.computeRefund(includeLockDataVal); - builder.spend(spendBuilder); - } - - builder.recalcAndSetFee(includeLockDataVal); - const feeResult = builder.calcFee(); - const transaction = builder.build(); - const allNotes = builder.allNotes(); - const txId = transaction.id; - const rawTx: RawTxV1 = { - version: 1, - id: transaction.id, - spends: transaction.spends, - }; - - const inputNotes = allNotes.filter((note): note is NoteV0 => isNoteV0(note)); - const spendConditions = inputNotes.map(() => targetSpendCondition); - const result: BuildV0MigrationTransactionResult = { - transaction, - txId, - fee: feeResult, - signRawTxPayload: { - rawTx, - notes: inputNotes, - spendConditions, - }, - }; - - if (debug) { - console.log('[SDK Migration] buildV0MigrationTransaction (full)', result); - } - - return result; -} - -/** - * Single-note migration (same logic as regular path, but one note). - * Picks any of the smallest notes (there may be multiple with the same size). - */ -async function buildV0MigrationTransactionSingleNote( - v0Notes: NoteV0[], - targetV1Pkh: string, - feePerWord?: Nicks, - settings?: Partial, - debug?: boolean -): Promise { - const targetSpendCondition = buildSinglePkhSpendCondition(targetV1Pkh); - const txSettings: TxEngineSettings = { - ...DEFAULT_TX_ENGINE_SETTINGS, - ...settings, - cost_per_word: feePerWord ?? settings?.cost_per_word ?? DEFAULT_TX_ENGINE_SETTINGS.cost_per_word, - }; - const builder = new wasm.TxBuilder(txSettings); - - const candidates: Array<{ note: NoteV0; assets: bigint }> = v0Notes.map(note => ({ - note, - assets: BigInt(note.assets), - })); - - if (!candidates.length) { - throw new Error('No v0 notes to migrate.'); - } - - const minAssets = candidates.reduce((min, c) => (c.assets < min ? c.assets : min), candidates[0].assets); - const selected = candidates.find(c => c.assets === minAssets)!; - - const spendBuilder = new wasm.SpendBuilder(selected.note, targetSpendCondition, null, null); - spendBuilder.computeRefund(false); - builder.spend(spendBuilder); - - builder.recalcAndSetFee(false); - const feeNicks = builder.calcFee(); - const transaction = builder.build(); - const allNotes = builder.allNotes(); - const rawTx: RawTxV1 = { - version: 1, - id: transaction.id, - spends: transaction.spends, - }; - - const feeNicksBigInt = BigInt(feeNicks); - const inputNotes = allNotes.filter((note): note is NoteV0 => isNoteV0(note)); - const spendConditions = inputNotes.map(() => targetSpendCondition); - const result: BuildV0MigrationSingleNoteResult = { - transaction, - txId: transaction.id, - fee: feeNicks, - migratedNicks: selected.assets.toString(), - migratedNock: Number(selected.assets) / NOCK_TO_NICKS, - selectedNoteNicks: selected.assets.toString(), - selectedNoteNock: Number(selected.assets) / NOCK_TO_NICKS, - feeNock: Number(feeNicksBigInt) / NOCK_TO_NICKS, - signRawTxPayload: { - rawTx, - notes: inputNotes, - spendConditions, - }, - }; - - if (debug) { - console.log('[SDK Migration] buildV0MigrationTransactionSingleNote', result); - } - - return result; -} - -/** - * Derive v0 address, query legacy notes, and build migration transaction in one step. - * - * @param options.singleNoteOnly - [TEMPORARY] When true, migrates 200 NOCK from one note. - * @param options.debug - [TEMPORARY] When true, logs the built result to console. - */ -export async function buildV0MigrationFromMnemonic( +export async function buildV0MigrationTx( mnemonic: string, grpcEndpoint: string, - targetV1Pkh: string, - passphrase?: string, - childIndex?: number, - feePerWord?: Nicks, - includeLockData?: boolean, - settings?: Partial, - options?: { singleNoteOnly?: boolean; debug?: boolean } -): Promise { - const discovery = await queryV0BalanceFromMnemonic(mnemonic, grpcEndpoint, passphrase, childIndex); - const buildOptions = options?.singleNoteOnly - ? { singleNoteOnly: true as const, debug: options?.debug } - : { debug: options?.debug }; - const built = await buildV0MigrationTransaction( - discovery.v0Notes, - targetV1Pkh, - feePerWord, - includeLockData, - settings, - buildOptions - ); - - const result = { - ...built, - discovery, - }; - - if (options?.debug) { - console.log('[SDK Migration] buildV0MigrationFromMnemonic', result); + targetV1Pkh?: string, + options?: { debug?: boolean } +): Promise { + const balanceResult = await queryV0Balance(mnemonic, grpcEndpoint); + if (!targetV1Pkh) { + return balanceResult; } - return result; -} + const debug = options?.debug ?? false; + const useSingleNote = debug; -/** - * Build migration from protobuf notes (matches extension API). - * Caller must have initialized WASM before using. - */ -export async function buildV0MigrationTransactionFromNotes( - v0NotesProtobuf: unknown[], - targetV1Pkh: string, - feePerWord: Nicks = '32768', - options?: { debug?: boolean } -): Promise { - const v0Notes: NoteV0[] = []; - for (const notePb of v0NotesProtobuf) { - const parsed = wasm.noteFromProtobuf(notePb as Parameters[0]); - if (isNoteV0(parsed)) { - v0Notes.push(parsed); + try { + const v0Notes = balanceResult.v0Notes; + if (!v0Notes.length) { + throw new Error('No v0 notes to migrate'); } - } - const result = await buildV0MigrationTransactionSingleNote( - v0Notes, - targetV1Pkh, - feePerWord, - undefined, - options?.debug ?? false - ); + const notesToUse: NoteV0[] = useSingleNote + ? (() => { + const sorted = [...v0Notes] + .map(n => ({ note: n, assets: BigInt(n.assets) })) + .sort((a, b) => (a.assets < b.assets ? -1 : a.assets > b.assets ? 1 : 0)); + return [sorted[0].note]; + })() + : v0Notes; + + const txSettings = defaultTxEngineSettings(); + const targetSpendCondition = buildSinglePkhSpendCondition(targetV1Pkh); + const refundLock = wasm.locky(targetSpendCondition); + const builder = new wasm.TxBuilder(txSettings); + + for (const note of notesToUse) { + const spendBuilder = new wasm.SpendBuilder(note, null, null, refundLock); + spendBuilder.computeRefund(false); + builder.spend(spendBuilder); + } - return result; + builder.recalcAndSetFee(false); + const feeNicks = builder.curFee(); + const transaction = builder.build(); + const allNotes = builder.allNotes(); + const rawTx: RawTxV1 = { + version: 1, + id: transaction.id, + spends: transaction.spends, + }; + + const inputNotes = allNotes.filter((note): note is NoteV0 => isNoteV0(note)); + const feeNock = Number(BigInt(feeNicks)) / NOCK_TO_NICKS; + + const migrated = useSingleNote + ? (() => { + const note = notesToUse[0]; + const nock = Number(BigInt(note.assets)) / NOCK_TO_NICKS; + return { + migratedNicks: BigInt(note.assets).toString(), + migratedNock: nock, + }; + })() + : { + migratedNicks: (BigInt(balanceResult.totalNicks) - BigInt(feeNicks)).toString(), + migratedNock: balanceResult.totalNock - feeNock, + }; + + const result: BuildV0MigrationTxResult = { + ...balanceResult, + txId: transaction.id, + fee: feeNicks, + feeNock, + signRawTxPayload: { + rawTx, + notes: inputNotes, + spendConditions: inputNotes.map(() => null), + refundLock, + }, + ...migrated, + }; + + if (debug) { + console.log('[SDK Migration] buildV0MigrationTx', result); + } + return result; + } catch (e) { + console.warn('[SDK Migration] Build failed, returning balance only:', e); + return balanceResult; + } } export type { - BuildV0MigrationFromMnemonicResult, - BuildV0MigrationTransactionResult, - BuildV0MigrationSingleNoteResult, + BuildV0MigrationTxResult, DerivedV0Address, - QueryV0BalanceFromMnemonicResult, - QueryV0BalanceResult, + V0BalanceResult, } from './migration-types.js'; From 82e165e15f54c63ebaebb5d065d8a04f239d28a8 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:46:42 -0400 Subject: [PATCH 11/11] update and fix bridging logic --- src/bridge-types.ts | 11 +++++++++++ src/bridge.ts | 46 +++++++++++++++++++++++++++------------------ src/index.ts | 1 + 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/bridge-types.ts b/src/bridge-types.ts index c0b0c9d..1552038 100644 --- a/src/bridge-types.ts +++ b/src/bridge-types.ts @@ -30,6 +30,15 @@ export interface BridgeConfig { expectedLockRoot?: string; } +/** Tx engine settings for TxBuilder (matches wasm.TxEngineSettings). */ +export interface TxEngineSettings { + tx_engine_version: 0 | 1 | 2; + tx_engine_patch: number; + min_fee: string; + cost_per_word: string; + witness_word_div: number; +} + /** * Parameters for building a bridge transaction. * Input notes and spend conditions are supplied by the consumer (e.g. from gRPC). @@ -47,6 +56,8 @@ export interface BridgeTransactionParams { refundPkh: string; /** Optional fee override in nicks */ feeOverride?: Nicks; + /** Optional: tx engine settings (Bythos at block ≥54000). When provided, overrides config.feePerWord. */ + txEngineSettings?: TxEngineSettings; } /** diff --git a/src/bridge.ts b/src/bridge.ts index fce7943..0e3f984 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -149,37 +149,46 @@ export async function buildBridgeTransaction( const refundPkhObj = wasm.pkhSingle(params.refundPkh); const refundLock: SpendCondition = wasm.spendConditionNewPkh(refundPkhObj); - const costPerWord = params.feeOverride ?? config.feePerWord; - const builder = new wasm.TxBuilder({ - tx_engine_version: 1, - tx_engine_patch: 0, - min_fee: '256', - cost_per_word: costPerWord, - witness_word_div: 1, - }); + const txSettings = + params.txEngineSettings ?? { + tx_engine_version: 1, + tx_engine_patch: 0, + min_fee: '256', + cost_per_word: params.feeOverride ?? config.feePerWord, + witness_word_div: 1, + }; + const builder = new wasm.TxBuilder(txSettings); + + let remainingGift = BigInt(params.amountInNicks); for (let i = 0; i < params.inputNotes.length; i++) { const note = params.inputNotes[i]; const spendCondition = params.spendConditions[i]; + const noteAssets = BigInt(note.assets ?? 0); + + const giftPortion = remainingGift < noteAssets ? remainingGift : noteAssets; + remainingGift -= giftPortion; const spendBuilder = new wasm.SpendBuilder(note, spendCondition, null, refundLock); - const parentHash = wasm.noteHash(note); - const seed: SeedV1 = { - output_source: null, - lock_root: bridgeLockRoot, - note_data: noteData, - gift: params.amountInNicks, - parent_hash: parentHash, - }; + if (giftPortion > 0n) { + const parentHash = wasm.noteHash(note); + const seed: SeedV1 = { + output_source: null, + lock_root: bridgeLockRoot, + note_data: noteData, + gift: String(giftPortion), + parent_hash: parentHash, + }; + spendBuilder.seed(seed); + } - spendBuilder.seed(seed); spendBuilder.computeRefund(false); builder.spend(spendBuilder); } builder.recalcAndSetFee(false); - const feeResult = builder.calcFee(); + const feeResult = builder.curFee(); const transaction = builder.build(); const txId = transaction.id; @@ -385,4 +394,5 @@ export type { BridgeTransactionParams, BridgeTransactionResult, BridgeValidationResult, + TxEngineSettings, } from './bridge-types.js'; diff --git a/src/index.ts b/src/index.ts index 966d44d..b625923 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,5 +10,6 @@ export * from './migration.js'; export * from './errors.js'; export * from './constants.js'; export * from './compat.js'; +export * from './bridge.js'; export * as wasm from './wasm.js'; export { initWasm } from './wasm.js';