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-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..46b3e6a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "README.md" ], "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-types.ts b/src/bridge-types.ts new file mode 100644 index 0000000..1552038 --- /dev/null +++ b/src/bridge-types.ts @@ -0,0 +1,93 @@ +/** + * 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; +} + +/** 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). + */ +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; + /** Optional: tx engine settings (Bythos at block ≥54000). When provided, overrides config.feePerWord. */ + txEngineSettings?: TxEngineSettings; +} + +/** + * 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..0e3f984 --- /dev/null +++ b/src/bridge.ts @@ -0,0 +1,398 @@ +/** + * 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 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 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); + + 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.computeRefund(false); + builder.spend(spendBuilder); + } + + builder.recalcAndSetFee(false); + const feeResult = builder.curFee(); + 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, + TxEngineSettings, +} from './bridge-types.js'; diff --git a/src/index.ts b/src/index.ts index f9e0d03..b625923 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,10 @@ 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'; +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..456503b --- /dev/null +++ b/src/migration-types.ts @@ -0,0 +1,50 @@ +/** + * 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, +} from '@nockbox/iris-wasm/iris_wasm.js'; + +export type { Nicks }; + +/** Base58 bare public key derived from mnemonic. */ +export type DerivedV0Address = string; + +/** 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; +} + +/** 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 | null)[]; + refundLock: LockRoot; + }; + migratedNicks?: Nicks; + migratedNock?: number; +} diff --git a/src/migration.ts b/src/migration.ts new file mode 100644 index 0000000..2bd9cac --- /dev/null +++ b/src/migration.ts @@ -0,0 +1,200 @@ +import type { + BuildV0MigrationTxResult, + DerivedV0Address, + V0BalanceResult, +} from './migration-types.js'; +import type { + Note, + Nicks, + NoteV0, + PbCom2BalanceEntry, + RawTxV1, + SpendCondition, + TxEngineSettings, +} from '@nockbox/iris-wasm/iris_wasm.js'; +import { base58 } from '@scure/base'; +import * as wasm from './wasm.js'; + +function buildSinglePkhSpendCondition(pkh: string): SpendCondition { + const pkhObj = wasm.pkhSingle(pkh); + return wasm.spendConditionNewPkh(pkhObj); +} + +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; +} + +function sumNicks(notes: NoteV0[]): string { + const total = notes.reduce((acc, note) => acc + BigInt(note.assets), 0n); + return total.toString(); +} + +const NOCK_TO_NICKS = 65_536; + +/** + * Derive legacy v0 address (base58 bare public key) from mnemonic. + */ +export function deriveV0AddressFromMnemonic(mnemonic: string): DerivedV0Address { + const master = wasm.deriveMasterKeyFromMnemonic(mnemonic); + try { + const publicKey = Uint8Array.from(master.publicKey); + return base58.encode(publicKey); + } finally { + master.free(); + } +} + +/** + * 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 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 ?? []; + for (const entry of entries) { + if (!isLegacyEntry(entry) || !entry.note) { + continue; + } + const parsed = wasm.noteFromProtobuf(entry.note); + if (isNoteV0(parsed)) { + v0Notes.push(parsed); + } + } + + 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, + }; +} + +function defaultTxEngineSettings(): TxEngineSettings { + return wasm.txEngineSettingsV1BythosDefault(); +} + +/** + * 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 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 buildV0MigrationTx( + mnemonic: string, + grpcEndpoint: string, + targetV1Pkh?: string, + options?: { debug?: boolean } +): Promise { + const balanceResult = await queryV0Balance(mnemonic, grpcEndpoint); + if (!targetV1Pkh) { + return balanceResult; + } + + const debug = options?.debug ?? false; + const useSingleNote = debug; + + try { + const v0Notes = balanceResult.v0Notes; + if (!v0Notes.length) { + throw new Error('No v0 notes to migrate'); + } + + 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); + } + + 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 { + BuildV0MigrationTxResult, + DerivedV0Address, + V0BalanceResult, +} from './migration-types.js';