UNPKG

splitwise-solana-client

Version:

Client SDK for Splitwise Solana Settlement

445 lines (444 loc) 17.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SplitwiseSettlement = exports.SDKError = void 0; const web3_js_1 = require("@solana/web3.js"); const anchor_1 = require("@project-serum/anchor"); const spl_token_1 = require("@solana/spl-token"); const buffer_1 = require("buffer"); const axios_1 = __importDefault(require("axios")); const events_1 = require("events"); const crypto_1 = require("crypto"); class SDKError extends Error { constructor(message, code, details) { super(message); this.code = code; this.details = details; this.name = 'SDKError'; } } exports.SDKError = SDKError; class SplitwiseSettlement extends events_1.EventEmitter { constructor(sdk, oracleEndpoint = 'http://localhost:3001', oracleApiKey, commitment = 'confirmed') { super(); this.sdk = sdk; this.oracleEndpoint = oracleEndpoint; this.oracleApiKey = oracleApiKey; this.commitment = commitment; this.subscriptions = new Map(); } /** * Initialize a new settlement for a Splitwise debt */ async initializeSettlement(params) { try { // Input validation if (!this.isValidAmount(params.amount)) { throw new SDKError('Invalid amount', 'INVALID_AMOUNT', { amount: params.amount }); } // Get oracle verification data const oracleData = await this.getOracleVerification(params.splitwiseId); // Generate commitment for front-running protection const nonce = web3_js_1.Keypair.generate().secretKey.slice(0, 32); const commitmentHash = await this.computeCommitmentHash({ splitwiseId: params.splitwiseId, amount: params.amount, debtor: params.debtorWallet, creditor: params.creditorWallet, nonce, }); // Find PDA for settlement account const [settlementAccount] = await this.findSettlementAddress(params.splitwiseId, params.debtorWallet, params.creditorWallet); // Verify token accounts exist or create them const [fromATA, toATA] = await Promise.all([ this.getOrCreateAssociatedTokenAccount(params.tokenMint, params.debtorWallet), this.getOrCreateAssociatedTokenAccount(params.tokenMint, params.creditorWallet), ]); // Create transaction const tx = await this.sdk.program.methods .initializeSettlement({ splitwiseId: params.splitwiseId, amount: new anchor_1.BN(params.amount), expiryTimestamp: new anchor_1.BN(params.expiryTimestamp), minPaymentAmount: new anchor_1.BN(params.minPaymentAmount || 0), splitwiseAmount: new anchor_1.BN(oracleData.splitwiseAmount), splitwiseCurrency: oracleData.splitwiseCurrency, verificationHash: oracleData.verificationHash, debtor: params.debtorWallet, creditor: params.creditorWallet, oracleData: oracleData, commitmentHash: commitmentHash, }) .accounts({ settlement: settlementAccount, from: params.debtorWallet, to: params.creditorWallet, fromTokenAccount: fromATA, toTokenAccount: toATA, tokenMint: params.tokenMint, tokenProgram: spl_token_1.TOKEN_PROGRAM_ID, systemProgram: web3_js_1.SystemProgram.programId, rent: web3_js_1.SYSVAR_RENT_PUBKEY, }) .transaction(); // Sign and send transaction const signature = await this.sendAndConfirmTransaction(tx); // Start monitoring the settlement await this.monitorSettlement(settlementAccount); return { transaction: tx, signature, settlementAccount, nonce, }; } catch (error) { throw this.handleError(error); } } /** * Make a payment towards a settlement */ async makePayment(params) { try { // Fetch and validate settlement const settlement = await this.getSettlement(params.settlementAccount); this.validateSettlementForPayment(settlement, params.amount); // Get fresh oracle data const oracleData = await this.getOracleVerification(settlement.splitwiseId); // Get token accounts const fromATA = await (0, spl_token_1.getAssociatedTokenAddress)(settlement.tokenMint, settlement.from); const toATA = await (0, spl_token_1.getAssociatedTokenAddress)(settlement.tokenMint, settlement.to); // Verify token accounts exist and have sufficient balance await this.validateTokenAccounts(fromATA, toATA, params.amount); // Create transaction const tx = await this.sdk.program.methods .makePayment({ amount: new anchor_1.BN(params.amount), verificationHash: oracleData.verificationHash, oracleUpdate: oracleData, commitmentProof: settlement.frontRunningProtection, }) .accounts({ settlement: params.settlementAccount, from: settlement.from, to: settlement.to, fromTokenAccount: fromATA, toTokenAccount: toATA, tokenProgram: spl_token_1.TOKEN_PROGRAM_ID, }) .transaction(); // Sign and send transaction const signature = await this.sendAndConfirmTransaction(tx); return { transaction: tx, signature }; } catch (error) { throw this.handleError(error); } } /** * Cancel a settlement */ async cancelSettlement(settlementAccount) { try { const settlement = await this.getSettlement(settlementAccount); const tx = await this.sdk.program.methods .cancelSettlement() .accounts({ settlement: settlementAccount, authority: settlement.from, }) .transaction(); const signature = await this.sendAndConfirmTransaction(tx); return { transaction: tx, signature }; } catch (error) { throw this.handleError(error); } } /** * Get settlement details */ async getSettlement(settlementAccount) { try { const account = await this.sdk.program.account.settlement.fetch(settlementAccount); // Convert the raw account data to our Settlement type return { status: account.status, splitwiseId: account.splitwiseId, amount: account.amount, remainingAmount: account.remainingAmount, minPaymentAmount: account.minPaymentAmount, from: account.from, to: account.to, tokenMint: account.tokenMint, frontRunningProtection: account.frontRunningProtection, isInitialized: account.isInitialized, expiryTimestamp: account.expiryTimestamp, }; } catch (error) { throw this.handleError(error); } } /** * Get all settlements for a user */ async getUserSettlements(userWallet) { try { const settlements = await this.sdk.program.account.settlement.all([ { memcmp: { offset: 8, // After discriminator bytes: userWallet.toBase58(), }, }, ]); // Convert each account to our Settlement type return settlements.map(({ publicKey, account }) => ({ publicKey, account: { status: account.status, splitwiseId: account.splitwiseId, amount: account.amount, remainingAmount: account.remainingAmount, minPaymentAmount: account.minPaymentAmount, from: account.from, to: account.to, tokenMint: account.tokenMint, frontRunningProtection: account.frontRunningProtection, isInitialized: account.isInitialized, expiryTimestamp: account.expiryTimestamp, }, })); } catch (error) { throw this.handleError(error); } } /** * Monitor a settlement for changes */ async monitorSettlement(settlementAccount) { try { const subscription = this.sdk.program.account.settlement.subscribe(settlementAccount, 'confirmed'); subscription.on('change', (account) => { const eventData = { settlementAccount, eventType: this.getEventType(account), data: account }; this.emit('settlementUpdate', eventData); }); this.subscriptions.set(settlementAccount.toString(), subscription); } catch (error) { throw this.handleError(error); } } getEventType(account) { switch (account.status) { case 'pending': return 'initialized'; case 'active': return 'payment'; case 'completed': return 'completed'; case 'cancelled': return 'cancelled'; case 'expired': return 'expired'; default: throw new Error(`Unknown settlement status: ${account.status}`); } } /** * Stop monitoring a settlement */ async stopMonitoring(settlementAccount) { const subscriptionId = this.subscriptions.get(settlementAccount.toBase58()); if (subscriptionId) { await this.sdk.program.removeEventListener(subscriptionId); this.subscriptions.delete(settlementAccount.toBase58()); } } /** * Get or create associated token account */ async getOrCreateAssociatedTokenAccount(mint, owner) { const ata = await (0, spl_token_1.getAssociatedTokenAddress)(mint, owner); try { await (0, spl_token_1.getAccount)(this.sdk.connection, ata); return ata; } catch (error) { const tx = new web3_js_1.Transaction().add((0, spl_token_1.createAssociatedTokenAccountInstruction)(this.sdk.provider.publicKey, ata, owner, mint)); await this.sendAndConfirmTransaction(tx); return ata; } } /** * Find settlement PDA address */ async findSettlementAddress(splitwiseId, debtor, creditor) { return web3_js_1.PublicKey.findProgramAddress([ buffer_1.Buffer.from('settlement'), buffer_1.Buffer.from(splitwiseId), debtor.toBuffer(), creditor.toBuffer(), ], this.sdk.program.programId); } /** * Send and confirm transaction with retry */ async sendAndConfirmTransaction(tx, signers = [], maxRetries = 3) { let lastError; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const signature = await (0, web3_js_1.sendAndConfirmTransaction)(this.sdk.connection, tx, [this.sdk.provider.wallet, ...signers], { commitment: this.commitment, }); return signature; } catch (error) { lastError = error; if (!this.isRetryableError(error)) { throw this.handleError(error); } await this.sleep(Math.pow(2, attempt) * 1000); } } throw this.handleError(lastError); } /** * Validate settlement for payment */ validateSettlementForPayment(settlement, amount) { if (!settlement.isInitialized) { throw new SDKError('Settlement not initialized', 'INVALID_SETTLEMENT_STATE'); } if (settlement.status !== 'active') { throw new SDKError('Settlement not in active state', 'INVALID_SETTLEMENT_STATE', { status: settlement.status }); } if (amount <= 0) { throw new SDKError('Payment amount must be positive', 'INVALID_AMOUNT', { amount }); } if (amount < settlement.minPaymentAmount.toNumber()) { throw new SDKError('Payment amount below minimum', 'AMOUNT_BELOW_MINIMUM', { amount, minimum: settlement.minPaymentAmount.toNumber() }); } if (amount > settlement.remainingAmount.toNumber()) { throw new SDKError('Payment amount exceeds remaining amount', 'AMOUNT_EXCEEDS_REMAINING', { amount, remaining: settlement.remainingAmount.toNumber() }); } const now = Math.floor(Date.now() / 1000); if (now >= settlement.expiryTimestamp.toNumber()) { throw new SDKError('Settlement expired', 'SETTLEMENT_EXPIRED', { expiry: settlement.expiryTimestamp.toNumber(), current: now }); } } /** * Validate token accounts */ async validateTokenAccounts(fromATA, toATA, amount) { try { const [fromAccount, toAccount] = await Promise.all([ (0, spl_token_1.getAccount)(this.sdk.connection, fromATA), (0, spl_token_1.getAccount)(this.sdk.connection, toATA), ]); // Convert token account amount to number const fromBalance = Number(fromAccount.amount); if (fromBalance < amount) { throw new SDKError('Insufficient balance', 'INSUFFICIENT_BALANCE', { balance: fromBalance, required: amount }); } } catch (error) { throw new SDKError('Invalid token accounts', 'INVALID_TOKEN_ACCOUNTS', { error }); } } /** * Check if error is retryable */ isRetryableError(error) { const retryableErrors = [ 'Network request failed', 'Transaction was not confirmed', 'Transaction simulation failed', ]; return retryableErrors.some(msg => error.message && error.message.includes(msg)); } /** * Handle and standardize errors */ handleError(error) { if (error instanceof SDKError) { return error; } // Handle program errors if (error.code && error.msg) { return new SDKError(error.msg, `PROGRAM_ERROR_${error.code}`, error); } // Handle RPC errors if (error.message && error.message.includes('JSON-RPC error')) { return new SDKError('RPC error', 'RPC_ERROR', error); } return new SDKError('Unknown error', 'UNKNOWN_ERROR', error); } sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } isValidAmount(amount) { return (typeof amount === 'number' && amount > 0 && Number.isFinite(amount) && amount <= Number.MAX_SAFE_INTEGER); } /** * Get oracle verification data */ async getOracleVerification(splitwiseId) { try { const response = await axios_1.default.get(`${this.oracleEndpoint}/verify/${splitwiseId}`, { headers: { 'X-API-Key': this.oracleApiKey } }); return { splitwiseAmount: response.data.splitwiseAmount, splitwiseCurrency: response.data.splitwiseCurrency, exchangeRate: response.data.exchangeRate, timestamp: response.data.timestamp, signatures: response.data.signatures, verificationHash: response.data.verificationHash }; } catch (error) { if (axios_1.default.isAxiosError(error)) { throw new SDKError('Oracle verification failed', 'ORACLE_ERROR', { status: error.response?.status, data: error.response?.data }); } throw error; } } /** * Compute commitment hash for front-running protection */ async computeCommitmentHash(params) { const data = buffer_1.Buffer.concat([ buffer_1.Buffer.from(params.splitwiseId), buffer_1.Buffer.from(params.amount.toString()), params.debtor.toBuffer(), params.creditor.toBuffer(), buffer_1.Buffer.from(params.nonce), ]); return (0, crypto_1.createHash)('sha256').update(data).digest(); } } exports.SplitwiseSettlement = SplitwiseSettlement;