splitwise-solana-client
Version:
Client SDK for Splitwise Solana Settlement
445 lines (444 loc) • 17.4 kB
JavaScript
"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;