/** * Consignment Reader * * Locates and reads the RGB consignment file that rgb-lib writes to the wallet's * transfer directory during sendBegin(). The consignment is required by the * gas-free service's /sign-psbt endpoint to cryptographically verify that the * RGB allocation matching the serviceFeeInvoice is present before co-signing. * * At the end of send_begin(), rgb-lib renames the temporary transfer directory * from `transfers/{hash}/` to `transfers/{txid}/`. The consignment file is then * at the fully deterministic path: * * {walletDir}/transfers/{txid}/{assetId_without_rgb:_prefix}/consignment_out * * For SegWit transactions the TXID covers only the non-witness serialization, so * it does not change when the service adds its co-signature. This means the TXID * can be computed from the *unsigned* PSBT that sendBegin() returns. * * @module ConsignmentReader */ import type { Wallet } from '../../../core/Wallet'; import type { Logger } from '../../../utils/logger'; import { GasFreeError, GasFreeErrorCode } from '../errors/GasFreeError'; import type { IConsignmentReader } from './IConsignmentReader'; import SHA256 from 'crypto-js/sha256'; import encHex from 'crypto-js/enc-hex'; import encLatin1 from 'crypto-js/enc-latin1'; import * as fs from 'fs/promises'; /** * ConsignmentReader * * Implements IConsignmentReader by: * 1. Calling wallet.getWalletDir() to obtain the base path. * 2. Parsing the unsigned PSBT (base64) to extract the unsigned transaction * bytes from the PSBT global map. * 3. Computing TXID = reverse(dSHA256(unsignedTxBytes)) via crypto.subtle. * 4. Constructing the deterministic path and reading it with fetch('file://'). */ export class ConsignmentReader implements IConsignmentReader { private readonly wallet: Wallet; private readonly logger?: Logger; constructor(wallet: Wallet, logger?: Logger) { this.wallet = wallet; this.logger = logger?.child('ConsignmentReader'); } /** * Derive the consignment path from the unsigned PSBT and read the file. * * Path: {walletDir}/transfers/{txid}/{assetIdNoPrefix}/consignment_out */ async readForAsset(assetId: string, unsignedPsbt: string): Promise { const walletDir = await this.wallet.getWalletDir(); const txid = await this.computeTxidFromPsbt(unsignedPsbt); const assetIdNoPrefix = assetId.replace(/^rgb:/, ''); const consignmentPath = `${walletDir}/transfers/${txid}/${assetIdNoPrefix}/consignment_out`; this.logger?.debug('Reading consignment from disk', { txid, path: consignmentPath, }); return await this.readFileAsBase64(consignmentPath); } // --------------------------------------------------------------------------- // PSBT parsing // --------------------------------------------------------------------------- /** * Extract the unsigned transaction from the PSBT global map and compute its * TXID via double SHA-256 + byte reversal (Bitcoin convention). * * PSBT format (BIP-174): * - Magic: 0x70 0x73 0x62 0x74 0xff ("psbt\xff") * - Global map: sequence of pairs * terminated by a 0x00 key-length byte * - PSBT_GLOBAL_UNSIGNED_TX: key = [0x00] (type 0x00, length 1) */ private async computeTxidFromPsbt(psbtBase64: string): Promise { const psbtBytes = Uint8Array.from(atob(psbtBase64), (c) => c.charCodeAt(0)); // Verify magic: "psbt" + 0xff if ( psbtBytes[0] !== 0x70 || psbtBytes[1] !== 0x73 || psbtBytes[2] !== 0x62 || psbtBytes[3] !== 0x74 || psbtBytes[4] !== 0xff ) { throw new GasFreeError( 'Invalid PSBT: missing magic bytes — cannot derive TXID', GasFreeErrorCode.PSBT_BUILD_FAILED ); } // Walk the global map to find PSBT_GLOBAL_UNSIGNED_TX (key type 0x00) let offset = 5; let unsignedTxBytes: Uint8Array | null = null; while (offset < psbtBytes.length) { const { value: keyLen, bytesRead: klSize } = this.readCompactSize( psbtBytes, offset ); offset += klSize; if (keyLen === 0) break; // end-of-map sentinel const keyType = psbtBytes[offset]; offset += keyLen; const { value: valueLen, bytesRead: vlSize } = this.readCompactSize( psbtBytes, offset ); offset += vlSize; const valueBytes = psbtBytes.slice(offset, offset + valueLen); offset += valueLen; if (keyType === 0x00 && keyLen === 1) { unsignedTxBytes = valueBytes; break; } } if (!unsignedTxBytes) { throw new GasFreeError( 'Could not locate PSBT_GLOBAL_UNSIGNED_TX in PSBT — cannot derive TXID', GasFreeErrorCode.PSBT_BUILD_FAILED ); } // dSHA256(unsignedTxBytes), then reverse for Bitcoin display byte order. // Use crypto-js for SHA-256 hashing since Web Crypto API is not available // in React Native. Convert Uint8Array to WordArray for crypto-js. const latin1String = Array.from(unsignedTxBytes) .map((byte) => String.fromCharCode(byte)) .join(''); const wordArray = encLatin1.parse(latin1String); const hash1 = SHA256(wordArray); const hash2 = SHA256(hash1); const txidHex = hash2.toString(encHex); // Reverse bytes for Bitcoin display format (little-endian to big-endian) const txidBytes = txidHex.match(/.{2}/g)?.reverse().join('') || ''; return txidBytes; } /** * Read a Bitcoin-style compact-size (varint) integer from `bytes` at `offset`. * Returns the decoded value and the number of bytes consumed. */ private readCompactSize( bytes: Uint8Array, offset: number ): { value: number; bytesRead: number } { const first = bytes[offset]!; if (first < 0xfd) return { value: first, bytesRead: 1 }; if (first === 0xfd) { return { value: bytes[offset + 1]! | (bytes[offset + 2]! << 8), bytesRead: 3, }; } if (first === 0xfe) { // Avoid sign-extension: multiply the high byte instead of shifting 24 bits return { value: bytes[offset + 1]! | (bytes[offset + 2]! << 8) | (bytes[offset + 3]! << 16) | (bytes[offset + 4]! * 0x1000000), bytesRead: 5, }; } // 0xff: 8-byte little-endian — PSBT sizes never exceed 32-bit range in practice return { value: bytes[offset + 1]! | (bytes[offset + 2]! << 8) | (bytes[offset + 3]! << 16) | (bytes[offset + 4]! * 0x1000000), bytesRead: 9, }; } // --------------------------------------------------------------------------- // File I/O // --------------------------------------------------------------------------- /** * Read a local file and return its contents as a base64-encoded string. * * For Node.js, we use fs.readFile since fetch() doesn't support file:// URLs. * For React Native, the Web Fetch API with file:// URI would be used instead. */ private async readFileAsBase64(filePath: string): Promise { try { // Node.js implementation using fs module const buffer = await fs.readFile(filePath); return buffer.toString('base64'); } catch (err) { throw new GasFreeError( `Failed to read consignment file at ${filePath}: ${ err instanceof Error ? err.message : String(err) }`, GasFreeErrorCode.PSBT_BUILD_FAILED, err instanceof Error ? err : undefined ); } } }