Skip to content

Commit f8b5062

Browse files
committed
fix: use split transaction for escrow settlement to avoid rent issues
- Settlement now sends beneficiary + fee in single atomic transaction - Avoids insufficient rent error on small SOL escrows - Falls back to sequential sends if split unavailable - Added sendSplitTransaction to BlockchainProvider interface - Added tests for settlement logic - Bump to v0.4.14
1 parent 3cbee55 commit f8b5062

File tree

5 files changed

+115
-11
lines changed

5 files changed

+115
-11
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "coinpayportal",
3-
"version": "0.4.13",
3+
"version": "0.4.14",
44
"private": true,
55
"type": "module",
66
"bin": {

packages/sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/coinpay",
3-
"version": "0.4.13",
3+
"version": "0.4.14",
44
"description": "CoinPay SDK & CLI — Accept cryptocurrency payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet and swap support",
55
"type": "module",
66
"main": "./src/index.js",
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
/**
4+
* Tests for escrow settlement route logic.
5+
* Verifies that split transactions are preferred for release+fee scenarios.
6+
*/
7+
8+
describe('Escrow Settlement Logic', () => {
9+
it('should use split transaction when releasing with fee', () => {
10+
const action = 'release';
11+
const feeAmount = 0.001;
12+
const hasSplitTx = true;
13+
const commissionWallet = 'CommissionWallet123';
14+
15+
const useSplit = action === 'release' && feeAmount > 0 && hasSplitTx && !!commissionWallet;
16+
expect(useSplit).toBe(true);
17+
});
18+
19+
it('should NOT use split transaction for refunds', () => {
20+
const action = 'refund';
21+
const feeAmount = 0.001;
22+
const hasSplitTx = true;
23+
const commissionWallet = 'CommissionWallet123';
24+
25+
const useSplit = action === 'release' && feeAmount > 0 && hasSplitTx && !!commissionWallet;
26+
expect(useSplit).toBe(false);
27+
});
28+
29+
it('should NOT use split transaction when no commission wallet', () => {
30+
const action = 'release';
31+
const feeAmount = 0.001;
32+
const hasSplitTx = true;
33+
const commissionWallet = null;
34+
35+
const useSplit = action === 'release' && feeAmount > 0 && hasSplitTx && !!commissionWallet;
36+
expect(useSplit).toBe(false);
37+
});
38+
39+
it('should NOT use split transaction when fee is 0', () => {
40+
const action = 'release';
41+
const feeAmount = 0;
42+
const hasSplitTx = true;
43+
const commissionWallet = 'CommissionWallet123';
44+
45+
const useSplit = action === 'release' && feeAmount > 0 && hasSplitTx && !!commissionWallet;
46+
expect(useSplit).toBe(false);
47+
});
48+
49+
it('should fall back to sequential when provider lacks sendSplitTransaction', () => {
50+
const action = 'release';
51+
const feeAmount = 0.001;
52+
const hasSplitTx = false;
53+
const commissionWallet = 'CommissionWallet123';
54+
55+
const useSplit = action === 'release' && feeAmount > 0 && hasSplitTx && !!commissionWallet;
56+
expect(useSplit).toBe(false);
57+
});
58+
59+
it('should calculate correct amounts for beneficiary and fee', () => {
60+
const depositedAmount = 0.011896577;
61+
const feeAmount = 0.000118965765629796;
62+
const amountToSend = depositedAmount - feeAmount;
63+
64+
expect(amountToSend).toBeCloseTo(0.011777611, 8);
65+
expect(amountToSend + feeAmount).toBeCloseTo(depositedAmount, 8);
66+
});
67+
68+
it('should send full deposited amount on refund (no fee)', () => {
69+
const depositedAmount = 0.011896577;
70+
const feeAmount = 0.000118965765629796;
71+
const action = 'refund';
72+
73+
const amountToSend = action === 'refund'
74+
? depositedAmount
75+
: depositedAmount - feeAmount;
76+
77+
expect(amountToSend).toBe(depositedAmount);
78+
});
79+
});

src/app/api/escrow/[id]/settle/route.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,38 @@ export async function POST(
120120
let txHash: string | undefined;
121121
let feeTxHash: string | undefined;
122122

123-
if (provider.sendTransaction) {
124-
// Forward to destination (beneficiary or depositor)
125-
// sendTransaction signature: (from, to, amount, privateKey) → string
123+
if (!provider.sendTransaction) {
124+
return NextResponse.json(
125+
{ error: `No transaction provider for chain ${escrow.chain}` },
126+
{ status: 500 }
127+
);
128+
}
129+
130+
// Use split transaction when releasing with fee — single atomic tx
131+
// This avoids rent/balance issues from sequential sends
132+
if (action === 'release' && escrow.fee_amount > 0 && provider.sendSplitTransaction && addressData.commission_wallet) {
133+
try {
134+
const recipients = [
135+
{ address: destinationAddress, amount: String(amountToSend) },
136+
{ address: addressData.commission_wallet, amount: String(escrow.fee_amount) },
137+
];
138+
console.log(`[Settle] Using split transaction for escrow ${escrowId}: ${JSON.stringify(recipients)}`);
139+
txHash = await provider.sendSplitTransaction(
140+
addressData.address,
141+
recipients,
142+
privateKey
143+
);
144+
// Both beneficiary and fee are in the same tx
145+
feeTxHash = txHash;
146+
} catch (splitError) {
147+
console.error(`Split transaction failed for escrow ${escrowId}, falling back to sequential:`, splitError);
148+
// Fall back to sequential sends
149+
txHash = undefined;
150+
}
151+
}
152+
153+
// Fallback: sequential sends (refunds, or if split failed, or no commission)
154+
if (!txHash) {
126155
txHash = await provider.sendTransaction(
127156
addressData.address,
128157
destinationAddress,
@@ -131,7 +160,7 @@ export async function POST(
131160
);
132161

133162
// If release (not refund), also send platform fee to commission wallet
134-
if (action === 'release' && escrow.fee_amount > 0) {
163+
if (action === 'release' && escrow.fee_amount > 0 && addressData.commission_wallet) {
135164
try {
136165
feeTxHash = await provider.sendTransaction(
137166
addressData.address,
@@ -144,11 +173,6 @@ export async function POST(
144173
// Non-fatal — main settlement still succeeded
145174
}
146175
}
147-
} else {
148-
return NextResponse.json(
149-
{ error: `No transaction provider for chain ${escrow.chain}` },
150-
{ status: 500 }
151-
);
152176
}
153177

154178
// Mark as settled

src/lib/blockchain/providers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export interface BlockchainProvider {
5151
getTransaction(txHash: string): Promise<TransactionDetails>;
5252
getRequiredConfirmations(): number;
5353
sendTransaction?(from: string, to: string, amount: string, privateKey: string): Promise<string>;
54+
sendSplitTransaction?(from: string, recipients: Array<{ address: string; amount: string }>, privateKey: string): Promise<string>;
5455
}
5556

5657
/**

0 commit comments

Comments
 (0)