/** * Gas-Free Transfer Module * * Main orchestration module for gas-free RGB transfers. * Coordinates ServiceClient and Wallet to execute complete * gas-free transfer workflow using external mining inputs. * * @module GasFreeModule */ import type { IFeatureModule } from '../../types/IFeatureModule'; import { Wallet } from '../../core/Wallet'; import type { Logger } from '../../utils/logger'; import { ServiceClient } from './client/ServiceClient'; import type { IServiceClient } from './client/IServiceClient'; import { ConsignmentReader } from './consignment/ConsignmentReader'; import type { IConsignmentReader } from './consignment/IConsignmentReader'; import type { GasFreeConfig } from './types/GasFreeConfig'; import { getGasFreeServiceUrl } from './types/GasFreeConfig'; import type { GasFreeTransferRequest, GasFreeTransferResult, } from './types/GasFreeRequest'; import type { FeeQuote, VerificationResult, PSBTBuildResult, PSBTSubmitResult, BroadcastResult, TransferState, } from './types'; import { GasFreeError, GasFreeErrorCode } from './errors/GasFreeError'; import type { AssignmentType } from '../../core/Interfaces'; import { Feature } from '../../types/Feature'; import { createFeeCalculator } from './utils/FeeCalculator'; /** * Gas-Free Transfer Module * * Implements complete gas-free transfer workflow: * 1. Generate fee quote from service * 2. Build PSBT with external mining inputs using Wallet * 3. Sign and finalize PSBT with Wallet * 4. Submit signed PSBT to service * 5. Verify transfer completion */ export class GasFreeModule implements IFeatureModule { public readonly name = Feature.GAS_FREE; public readonly version = '1.0.0'; public readonly config: GasFreeConfig; private wallet?: Wallet; private logger?: Logger; private serviceClient?: IServiceClient; private consignmentReader?: IConsignmentReader; private ready = false; private currentState: TransferState = { status: 'idle' }; constructor(config: GasFreeConfig) { this.config = config; } /** * Initialize the Gas-Free module * * @param wallet - Wallet instance for PSBT operations (REQUIRED) * @param logger - Logger instance for consistent logging */ async initialize(wallet?: Wallet, logger?: Logger): Promise { if (!wallet) { throw GasFreeError.initializationFailed( 'Gas-Free feature requires Wallet instance' ); } // Store logger (create child logger with module prefix) this.logger = logger?.child('GasFree'); const serviceUrl = getGasFreeServiceUrl(this.config.environment); this.logger?.debug('Initializing with config', { baseUrl: serviceUrl, timeout: this.config.timeout, }); try { // Validate configuration this.validateConfig(this.config); // Store wallet this.wallet = wallet; // Initialize service client this.serviceClient = new ServiceClient( { apiKey: this.config.apiKey || '', baseUrl: serviceUrl, timeout: this.config.timeout || 30000, }, this.logger ); // Initialize consignment reader this.consignmentReader = new ConsignmentReader(wallet, this.logger); this.ready = true; this.logger?.debug('Initialized successfully'); } catch (error) { this.logger?.error('Initialization failed', error); throw GasFreeError.initializationFailed( error instanceof Error ? error.message : String(error) ); } } /** * Cleanup and release resources */ async cleanup(): Promise { await this.dispose(); } /** * Get current status */ getStatus(): { enabled: boolean; ready: boolean; [key: string]: unknown } { return { enabled: this.config.enabled, ready: this.ready, currentState: this.currentState.status, }; } /** * Check if module is ready */ isReady(): boolean { return this.ready && !!this.wallet && !!this.serviceClient; } /** * Stage 1: Request fee quote from service * * Generates a fee quote showing service fee (paid by user in RGB assets). * The quote has an expiration time after which it cannot be used. * * @param request - Transfer request parameters * @returns Fee quote with pricing and expiration details * * @example * ```typescript * const quote = await gasFree.requestFeeQuote({ * userId: 'user-123', * assetId: 'rgb:...', * amount: '100', * recipientInvoice: 'rgb:...' * }); * console.log('Service fee:', quote.serviceFeeAmount); * ``` */ async requestFeeQuote(request: GasFreeTransferRequest): Promise { this.logger?.debug('Requesting fee quote', { assetId: request.assetId, transferAmount: request.transferAmount, }); // Check module is ready if (!this.isReady()) { throw GasFreeError.notInitialized(); } // Reset state this.currentState = { status: 'idle' }; try { const feeQuote = await this.generateFeeQuote(request); this.logger?.debug('Fee quote generated successfully', { quoteId: feeQuote.quoteId, }); return feeQuote; } catch (error) { this.currentState.status = 'failed'; this.currentState.error = error instanceof Error ? error : new Error(String(error)); this.logger?.error('Fee quote request failed', error); if (error instanceof GasFreeError) { throw error; } throw new GasFreeError( 'Failed to generate fee quote', GasFreeErrorCode.UNKNOWN, error instanceof Error ? error : undefined ); } } /** * Stage 2: Confirm and execute transfer using a fee quote (One-shot method) * * Executes the complete transfer workflow: * 1. Build PSBT with external mining inputs * 2. Submit unsigned PSBT to service for co-signing * 3. Sign RGB inputs and broadcast transaction * 4. Verify transfer completion with service * * For advanced users who need more control, use the individual stage methods: * - buildPSBT() * - submitPSBT() * - broadcastTransfer() * - verifyTransfer() * * @param request - Original transfer request parameters * @param feeQuote - Fee quote obtained from requestFeeQuote() * @returns Transfer result with txid and fee breakdown * * @example * ```typescript * const quote = await gasFree.requestFeeQuote(request); * const result = await gasFree.confirmTransfer(request, quote); * console.log('Transaction ID:', result.txid); * ``` */ async confirmTransfer( request: GasFreeTransferRequest, feeQuote: FeeQuote ): Promise { this.logger?.debug('Confirming transfer with quote', { quoteId: feeQuote.quoteId, assetId: request.assetId, transferAmount: request.transferAmount, }); // Check module is ready if (!this.isReady()) { throw GasFreeError.notInitialized(); } try { // Check quote not expired before starting const expiresAt = new Date(feeQuote.expiresAt).getTime(); if (expiresAt < Date.now()) { throw new GasFreeError( `Fee quote ${feeQuote.quoteId} has expired`, GasFreeErrorCode.QUOTE_EXPIRED, undefined, { quoteId: feeQuote.quoteId } ); } // Step 1: Build PSBT with external inputs (unsigned — service must sign first) const psbtResult = await this.buildPSBT(request, feeQuote); // Step 2: Submit UNSIGNED PSBT + consignment to service. // The service cryptographically verifies the RGB fee allocation in the // consignment, then co-signs the mining input and returns the PSBT with // its signature attached (service's mining input is signed; user's RGB // inputs are still unsigned at this point). const submitResult = await this.submitPSBT( feeQuote.quoteId, psbtResult, request.assetId ); // Step 3: User signs RGB inputs + broadcast via sendEnd. // wallet.signPsbt() adds the user's signature to the service-signed PSBT. // wallet.sendEnd() finalizes all inputs, posts consignments to the RGB // proxy, and broadcasts the transaction. const { txid, userSignedPsbt } = await this.broadcastTransfer( submitResult.signedPsbtBase64 ); // Step 4: Notify service of successful broadcast await this.verifyTransfer(feeQuote.quoteId, txid, userSignedPsbt); // Mark as verified this.currentState.status = 'verified'; this.logger?.debug('Transfer completed successfully', { txid }); return { txid, consignment: submitResult.consignmentBase64, serviceFee: feeQuote.serviceFeeAmount, quoteId: feeQuote.quoteId, }; } catch (error) { this.currentState.status = 'failed'; this.currentState.error = error instanceof Error ? error : new Error(String(error)); this.logger?.error('Transfer confirmation failed', error); // Re-throw GasFreeErrors as-is if (error instanceof GasFreeError) { throw error; } // Wrap other errors throw new GasFreeError( 'Gas-free transfer failed', GasFreeErrorCode.UNKNOWN, error instanceof Error ? error : undefined ); } } /** * Get current transfer state */ getState(): TransferState { return { ...this.currentState }; } /** * Estimate service fee for a transfer amount * @param transferAmount - Amount being transferred in base units (e.g., 100250000 for 100.25 TUSDT with precision 6) * @returns Object with fee in asset base units, USD, and effective percentage * * @example * ```typescript * const gasFree = sdk.gasFree(); * * // Estimate fee for 100 TUSDT (100000000 base units with precision 6) * const fee = gasFree.estimateFee(100000000); * console.log(`Fee: ${fee.feeInAsset} base units`); // 500000 ($0.50 minimum) * console.log(`Fee: $${fee.feeUsd}`); // $0.50 * console.log(`Rate: ${fee.effectivePercentage}%`); // 0.5% * ``` */ estimateFee(transferAmount: number): { feeInAsset: number; feeUsd: number; effectivePercentage: number; } { const calculator = createFeeCalculator(); // Use hardcoded constants (1:1 USD rate, precision 6) // These will be made dynamic in the future return calculator.estimateFeeInAsset(transferAmount, 1, 6); } /** * Dispose module and cleanup resources */ async dispose(): Promise { this.logger?.debug('Disposing module'); this.wallet = undefined; this.logger = undefined; this.serviceClient = undefined; this.ready = false; this.currentState = { status: 'idle' }; } /** * Step 1: Generate fee quote from service */ private async generateFeeQuote(request: GasFreeTransferRequest) { this.logger?.debug('Step 1: Generating fee quote'); const feeQuote = await this.serviceClient!.generateFeeQuote({ userId: request.userId, assetId: request.assetId, numInputs: request.numInputs || 1, // defaults to 1 inputs if not provided (1 user input) numOutputs: request.numOutputs || 2, // defaults to 2 outputs if not provided (1 OP_RETURN, 1 user change ) recipientInvoice: request.recipientInvoice, transferAmount: request.transferAmount, }); // Check quote expiration const expiresAt = new Date(feeQuote.expiresAt).getTime(); if (expiresAt < Date.now()) { throw new GasFreeError( `Fee quote ${feeQuote.quoteId} has expired`, GasFreeErrorCode.QUOTE_EXPIRED, undefined, { quoteId: feeQuote.quoteId } ); } this.currentState.status = 'quote-requested'; this.currentState.quoteId = feeQuote.quoteId; this.currentState.quoteExpiresAt = expiresAt; this.logger?.debug('Fee quote generated', { quoteId: feeQuote.quoteId, miningFee: feeQuote.miningFeeSats, serviceFee: feeQuote.serviceFeeAmount, expiresAt: feeQuote.expiresAt, }); return feeQuote; } /** * Advanced Stage 2a: Build PSBT with external mining inputs * * Builds an unsigned PSBT that includes: * - External mining input from the service (provides BTC for fees) * - User's RGB asset inputs * - Two RGB recipients: merchant + service fee * - External change output (BTC change back to service) * * This method writes the RGB consignment file to disk which will be read * in the next stage (submitPSBT). * * @param request - Transfer request parameters * @param feeQuote - Fee quote from requestFeeQuote() * @returns PSBT build result with unsigned PSBT and mining details * * @example * ```typescript * const quote = await gasFree.requestFeeQuote(request); * const psbtResult = await gasFree.buildPSBT(request, quote); * console.log('Unsigned PSBT:', psbtResult.unsignedPsbt); * ``` */ async buildPSBT( request: GasFreeTransferRequest, feeQuote: FeeQuote ): Promise { this.logger?.debug('Step 2: Building PSBT with external inputs'); // Check quote not expired const expiresAt = new Date(feeQuote.expiresAt).getTime(); if (expiresAt < Date.now()) { throw new GasFreeError( `Fee quote ${feeQuote.quoteId} has expired`, GasFreeErrorCode.QUOTE_EXPIRED, undefined, { quoteId: feeQuote.quoteId } ); } const externalInputs = [ { txid: feeQuote.miningUTXO.txid, vout: feeQuote.miningUTXO.vout, value: feeQuote.miningUTXO.value, scriptPubkey: feeQuote.miningUTXO.script_pubkey, }, ]; // External change output (service's BTC change back to mining wallet) const externalOutputs = feeQuote.miningChangeUTXO ? [ { address: feeQuote.miningChangeUTXO.address, value: feeQuote.miningChangeUTXO.value, }, ] : undefined; // Decode RGB invoices to extract recipient IDs and transport endpoints const decodedRecipientInvoice = await this.wallet!.decodeInvoice( request.recipientInvoice ); const decodedServiceInvoice = await this.wallet!.decodeInvoice( feeQuote.serviceFeeInvoice ); this.logger?.debug('Decoded RGB invoices', { recipient: decodedRecipientInvoice, service: decodedServiceInvoice, }); const recipientIsWitness = feeQuote.witnessUtxoFundingSats > 0; // Build recipient map for sendBegin // Note: Recipient requires assignment field with type and amount. // Important: Decoded invoices may have 'ANY' type (wildcard), but sendBegin // requires a concrete assignment type. Always use 'FUNGIBLE' for asset transfers. const recipientMap: Record< string, Array<{ recipientId: string; witnessData?: { amountSat: number; blinding?: number }; assignment: { type: AssignmentType; amount: number }; transportEndpoints: string[]; }> > = { [request.assetId]: [ { // Recipient 1: The actual transfer to the merchant / end recipient recipientId: decodedRecipientInvoice.recipientId, ...(recipientIsWitness && { witnessData: { amountSat: feeQuote.witnessUtxoFundingSats, blinding: 0, // Default blinding factor (rgb-lib requires this field) }, }), assignment: { // Use FUNGIBLE type for asset transfers (invoice may have 'ANY' wildcard type) type: 'FUNGIBLE', // transferAmount is already in base units (with precision included) amount: request.transferAmount, }, transportEndpoints: decodedRecipientInvoice.transportEndpoints, }, { // Recipient 2: Service fee payment — the service receives RGB assets // as payment for covering the Bitcoin mining fee. recipientId: decodedServiceInvoice.recipientId, assignment: { // Use FUNGIBLE type for service fee (invoice may have 'ANY' wildcard type) type: 'FUNGIBLE', // Use service fee amount from quote amount: feeQuote.serviceFeeAmount, // includes precision }, transportEndpoints: decodedServiceInvoice.transportEndpoints, }, ], }; // Use Wallet's sendBegin to build the PSBT with external inputs. // This call ONLY creates the unsigned PSBT + writes consignment files to disk. const unsignedPsbt = await this.wallet!.sendBegin( recipientMap, true, // donation:true (instant broadcast; skips counterparty ACK approval before broadcasting) request.feeRate || 5, request.minConfirmations || 1, externalInputs, externalOutputs ); this.logger?.debug('PSBT built via sendBegin', { externalInputs: externalInputs.length, miningFee: feeQuote.miningFeeSats, }); this.currentState.status = 'psbt-built'; return { unsignedPsbt, miningFee: feeQuote.miningFeeSats, externalInputs, }; } /** * Advanced Stage 2b: Submit unsigned PSBT to service for co-signing * * Submits the unsigned PSBT along with the RGB consignment to the service. * The service will: * 1. Cryptographically verify the RGB fee allocation in the consignment * 2. Co-sign the mining input * 3. Return the PSBT with its signature attached * * Note: User's RGB inputs remain unsigned at this stage. * * @param quoteId - Quote ID from the fee quote * @param psbtResult - PSBT build result from buildPSBT() * @param assetId - Asset ID being transferred * @returns Submit result with service-signed PSBT * * @example * ```typescript * const submitResult = await gasFree.submitPSBT( * quote.quoteId, * psbtResult, * request.assetId * ); * console.log('Service signed PSBT:', submitResult.signedPsbtBase64); * ``` */ async submitPSBT( quoteId: string, psbtResult: PSBTBuildResult, assetId: string ): Promise { this.logger?.debug('Step 3: Reading consignment and submitting to service'); const consignmentBase64 = await this.consignmentReader!.readForAsset( assetId, psbtResult.unsignedPsbt ); const submitResult = await this.serviceClient!.signPsbt({ quoteId, psbtBase64: psbtResult.unsignedPsbt, consignmentBase64, }); this.currentState.status = 'submitted'; this.currentState.txid = submitResult.transactionId; this.logger?.debug('PSBT submitted successfully', { txid: submitResult.transactionId, }); return { ...submitResult, consignmentBase64 }; } /** * Advanced Stage 2c: Sign RGB inputs and broadcast transaction * * Takes the PSBT returned by the service (mining input already signed), * adds the user's RGB input signatures, then calls wallet.sendEnd() which: * - Finalizes all inputs (combines witness data) * - Posts consignments to the RGB proxy * - Broadcasts the transaction to the Bitcoin network * * @param serviceSignedPsbt - Service-signed PSBT from submitPSBT() * @returns Broadcast result with txid and fully-signed PSBT * * @example * ```typescript * const broadcastResult = await gasFree.broadcastTransfer( * submitResult.signedPsbtBase64 * ); * console.log('Transaction ID:', broadcastResult.txid); * console.log('Fully signed PSBT:', broadcastResult.userSignedPsbt); * ``` */ async broadcastTransfer(serviceSignedPsbt: string): Promise { this.logger?.debug( 'Step 4: User signs RGB inputs and broadcasts via sendEnd' ); // Add the user's RGB input signatures to the service-signed PSBT const userSignedPsbt = await this.wallet!.signPsbt(serviceSignedPsbt); this.logger?.debug('Completely signed PSBT', { userSignedPsbt }); // sendEnd: finalizes all inputs, posts consignments to RGB proxy, broadcasts const operationResult = await this.wallet!.sendEnd(userSignedPsbt); this.logger?.debug('Transaction broadcast via sendEnd', { operationResult, }); this.currentState.status = 'broadcasted'; this.currentState.txid = operationResult.txid; this.logger?.debug('Transaction broadcast via sendEnd', { txid: operationResult.txid, batchTransferIdx: operationResult.batchTransferIdx, }); return { txid: operationResult.txid, userSignedPsbt }; } /** * Advanced Stage 2d: Verify transfer completion with service * * Notifies the service that the transaction has been broadcast. The service * will: * - Look up the txid in mempool to confirm it landed * - Call its own wallet.refresh() to claim the RGB service fee * * @param quoteId - Quote ID from the fee quote * @param txid - Transaction ID from broadcastTransfer() * @param signedPsbtBase64 - Base64-encoded signed PSBT * @returns Verification result from the service * * @example * ```typescript * const verifyResult = await gasFree.verifyTransfer( * quote.quoteId, * broadcastResult.txid, * signedPsbtBase64 * ); * console.log('Verification status:', verifyResult.status); * console.log('In mempool:', verifyResult.inMempool); * ``` */ async verifyTransfer( quoteId: string, txid: string, signedPsbtBase64: string ): Promise { this.logger?.debug('Step 4: Verifying transfer'); const verifyResult = await this.serviceClient!.verifyTransfer({ quoteId, transferSuccess: true, txid, signedPsbtBase64, }); this.logger?.debug('Transfer verified', { txid: verifyResult.transactionId, status: verifyResult.status, }); this.currentState.status = 'verified'; return verifyResult; } /** * Validate configuration */ private validateConfig(config: GasFreeConfig): void { if (!config.apiKey || config.apiKey.trim().length === 0) { throw new GasFreeError( 'API key is required', GasFreeErrorCode.INVALID_REQUEST ); } if (config.timeout && config.timeout < 1000) { throw new GasFreeError( 'Timeout must be at least 1000ms', GasFreeErrorCode.INVALID_REQUEST ); } } } /** * Factory function to create GasFreeModule instance * * @param config - Gas-Free configuration */ export function createGasFreeModule(config: GasFreeConfig): GasFreeModule { return new GasFreeModule(config); }