@agirails/sdk 2.0.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +183 -0
- package/dist/ACTPClient.d.ts +52 -0
- package/dist/ACTPClient.d.ts.map +1 -0
- package/dist/ACTPClient.js +120 -0
- package/dist/ACTPClient.js.map +1 -0
- package/dist/abi/ACTPKernel.json +1340 -0
- package/dist/abi/ERC20.json +38 -0
- package/dist/abi/EscrowVault.json +64 -0
- package/dist/builders/DeliveryProofBuilder.d.ts +37 -0
- package/dist/builders/DeliveryProofBuilder.d.ts.map +1 -0
- package/dist/builders/DeliveryProofBuilder.js +165 -0
- package/dist/builders/DeliveryProofBuilder.js.map +1 -0
- package/dist/builders/QuoteBuilder.d.ts +68 -0
- package/dist/builders/QuoteBuilder.d.ts.map +1 -0
- package/dist/builders/QuoteBuilder.js +255 -0
- package/dist/builders/QuoteBuilder.js.map +1 -0
- package/dist/builders/index.d.ts +3 -0
- package/dist/builders/index.d.ts.map +1 -0
- package/dist/builders/index.js +10 -0
- package/dist/builders/index.js.map +1 -0
- package/dist/config/networks.d.ts +27 -0
- package/dist/config/networks.d.ts.map +1 -0
- package/dist/config/networks.js +103 -0
- package/dist/config/networks.js.map +1 -0
- package/dist/errors/index.d.ts +38 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +87 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol/ACTPKernel.d.ts +30 -0
- package/dist/protocol/ACTPKernel.d.ts.map +1 -0
- package/dist/protocol/ACTPKernel.js +261 -0
- package/dist/protocol/ACTPKernel.js.map +1 -0
- package/dist/protocol/EASHelper.d.ts +23 -0
- package/dist/protocol/EASHelper.d.ts.map +1 -0
- package/dist/protocol/EASHelper.js +106 -0
- package/dist/protocol/EASHelper.js.map +1 -0
- package/dist/protocol/EscrowVault.d.ts +24 -0
- package/dist/protocol/EscrowVault.d.ts.map +1 -0
- package/dist/protocol/EscrowVault.js +114 -0
- package/dist/protocol/EscrowVault.js.map +1 -0
- package/dist/protocol/EventMonitor.d.ts +18 -0
- package/dist/protocol/EventMonitor.d.ts.map +1 -0
- package/dist/protocol/EventMonitor.js +92 -0
- package/dist/protocol/EventMonitor.js.map +1 -0
- package/dist/protocol/MessageSigner.d.ts +23 -0
- package/dist/protocol/MessageSigner.d.ts.map +1 -0
- package/dist/protocol/MessageSigner.js +178 -0
- package/dist/protocol/MessageSigner.js.map +1 -0
- package/dist/protocol/ProofGenerator.d.ts +22 -0
- package/dist/protocol/ProofGenerator.d.ts.map +1 -0
- package/dist/protocol/ProofGenerator.js +64 -0
- package/dist/protocol/ProofGenerator.js.map +1 -0
- package/dist/protocol/QuoteBuilder.d.ts +2 -0
- package/dist/protocol/QuoteBuilder.d.ts.map +1 -0
- package/dist/protocol/QuoteBuilder.js +7 -0
- package/dist/protocol/QuoteBuilder.js.map +1 -0
- package/dist/types/eip712.d.ts +106 -0
- package/dist/types/eip712.d.ts.map +1 -0
- package/dist/types/eip712.js +84 -0
- package/dist/types/eip712.js.map +1 -0
- package/dist/types/escrow.d.ts +18 -0
- package/dist/types/escrow.d.ts.map +1 -0
- package/dist/types/escrow.js +3 -0
- package/dist/types/escrow.js.map +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +22 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/message.d.ts +109 -0
- package/dist/types/message.d.ts.map +1 -0
- package/dist/types/message.js +3 -0
- package/dist/types/message.js.map +1 -0
- package/dist/types/state.d.ts +19 -0
- package/dist/types/state.d.ts.map +1 -0
- package/dist/types/state.js +49 -0
- package/dist/types/state.js.map +1 -0
- package/dist/types/transaction.d.ts +36 -0
- package/dist/types/transaction.d.ts.map +1 -0
- package/dist/types/transaction.js +3 -0
- package/dist/types/transaction.js.map +1 -0
- package/dist/utils/IPFSClient.d.ts +37 -0
- package/dist/utils/IPFSClient.d.ts.map +1 -0
- package/dist/utils/IPFSClient.js +128 -0
- package/dist/utils/IPFSClient.js.map +1 -0
- package/dist/utils/NonceManager.d.ts +34 -0
- package/dist/utils/NonceManager.d.ts.map +1 -0
- package/dist/utils/NonceManager.js +114 -0
- package/dist/utils/NonceManager.js.map +1 -0
- package/dist/utils/ReceivedNonceTracker.d.ts +35 -0
- package/dist/utils/ReceivedNonceTracker.d.ts.map +1 -0
- package/dist/utils/ReceivedNonceTracker.js +196 -0
- package/dist/utils/ReceivedNonceTracker.js.map +1 -0
- package/dist/utils/canonicalJson.d.ts +4 -0
- package/dist/utils/canonicalJson.d.ts.map +1 -0
- package/dist/utils/canonicalJson.js +21 -0
- package/dist/utils/canonicalJson.js.map +1 -0
- package/dist/utils/computeTypeHash.d.ts +3 -0
- package/dist/utils/computeTypeHash.d.ts.map +1 -0
- package/dist/utils/computeTypeHash.js +30 -0
- package/dist/utils/computeTypeHash.js.map +1 -0
- package/dist/utils/validation.d.ts +6 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +46 -0
- package/dist/utils/validation.js.map +1 -0
- package/package.json +73 -0
- package/src/ACTPClient.ts +276 -0
- package/src/__tests__/ProofGenerator.test.ts +124 -0
- package/src/__tests__/QuoteBuilder.test.ts +516 -0
- package/src/__tests__/StateMachine.test.ts +82 -0
- package/src/__tests__/builders/DeliveryProofBuilder.test.ts +581 -0
- package/src/__tests__/integration/ACTPClient.test.ts +263 -0
- package/src/__tests__/integration.test.ts +289 -0
- package/src/__tests__/protocol/EASHelper.test.ts +472 -0
- package/src/__tests__/protocol/EventMonitor.test.ts +382 -0
- package/src/__tests__/security/ACTPKernel.security.test.ts +1167 -0
- package/src/__tests__/security/EscrowVault.security.test.ts +570 -0
- package/src/__tests__/security/MessageSigner.security.test.ts +286 -0
- package/src/__tests__/security/NonceReplay.security.test.ts +501 -0
- package/src/__tests__/security/validation.security.test.ts +376 -0
- package/src/__tests__/utils/IPFSClient.test.ts +262 -0
- package/src/__tests__/utils/NonceManager.test.ts +205 -0
- package/src/__tests__/utils/canonicalJson.test.ts +153 -0
- package/src/abi/ACTPKernel.json +1340 -0
- package/src/abi/ERC20.json +40 -0
- package/src/abi/EscrowVault.json +66 -0
- package/src/builders/DeliveryProofBuilder.ts +326 -0
- package/src/builders/QuoteBuilder.ts +483 -0
- package/src/builders/index.ts +17 -0
- package/src/config/networks.ts +165 -0
- package/src/errors/index.ts +130 -0
- package/src/index.ts +108 -0
- package/src/protocol/ACTPKernel.ts +625 -0
- package/src/protocol/EASHelper.ts +197 -0
- package/src/protocol/EscrowVault.ts +237 -0
- package/src/protocol/EventMonitor.ts +161 -0
- package/src/protocol/MessageSigner.ts +336 -0
- package/src/protocol/ProofGenerator.ts +119 -0
- package/src/protocol/QuoteBuilder.ts +15 -0
- package/src/types/eip712.ts +175 -0
- package/src/types/escrow.ts +26 -0
- package/src/types/index.ts +10 -0
- package/src/types/message.ts +145 -0
- package/src/types/state.ts +77 -0
- package/src/types/transaction.ts +54 -0
- package/src/utils/IPFSClient.ts +248 -0
- package/src/utils/NonceManager.ts +293 -0
- package/src/utils/ReceivedNonceTracker.ts +397 -0
- package/src/utils/canonicalJson.ts +38 -0
- package/src/utils/computeTypeHash.ts +50 -0
- package/src/utils/validation.ts +82 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EscrowVault Security Test Suite
|
|
3
|
+
*
|
|
4
|
+
* CRITICAL: This module prepares token approvals for escrow creation.
|
|
5
|
+
* Actual escrow creation happens in ACTPKernel.linkEscrow() (tested in ACTPKernel integration tests).
|
|
6
|
+
*
|
|
7
|
+
* Per AIP-3 specification, escrow creation is atomic inside Kernel.linkEscrow(),
|
|
8
|
+
* not a separate EscrowVault SDK call. This test suite validates:
|
|
9
|
+
* 1. Token approval safety (USDC race condition mitigation)
|
|
10
|
+
* 2. Escrow release security (multi-recipient disbursement)
|
|
11
|
+
*
|
|
12
|
+
* Coverage Target: 90%+ (statements, functions, lines), 85%+ (branches)
|
|
13
|
+
*
|
|
14
|
+
* Security Test Categories:
|
|
15
|
+
* 1. Token Approval Safety (10+ tests) - Pre-escrow USDC approval
|
|
16
|
+
* 2. Escrow Release Security (10 tests) - Post-settlement disbursement
|
|
17
|
+
* 3. USDC Race Condition Mitigation (5+ tests) - Reset-to-zero pattern
|
|
18
|
+
* 4. Gas Estimation Buffers (2+ tests) - Dynamic gas buffers
|
|
19
|
+
*
|
|
20
|
+
* References:
|
|
21
|
+
* - Security Analysis: /Testnet/tests/SDK_SECURITY_ANALYSIS-Ultra-Think.md
|
|
22
|
+
* - AIP-3 §3.2: Escrow Linking Workflow
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { EscrowVault } from '../../protocol/EscrowVault';
|
|
26
|
+
|
|
27
|
+
// Mock ethers Contract
|
|
28
|
+
const mockContract = {
|
|
29
|
+
estimateGas: {
|
|
30
|
+
disburse: jest.fn().mockResolvedValue(BigInt(80000)),
|
|
31
|
+
approve: jest.fn().mockResolvedValue(BigInt(50000))
|
|
32
|
+
},
|
|
33
|
+
disburse: jest.fn().mockResolvedValue({
|
|
34
|
+
wait: jest.fn().mockResolvedValue({})
|
|
35
|
+
}),
|
|
36
|
+
escrows: jest.fn().mockResolvedValue({
|
|
37
|
+
kernel: '0x' + '1'.repeat(40),
|
|
38
|
+
txId: '0x' + '2'.repeat(64),
|
|
39
|
+
token: '0x' + '3'.repeat(40),
|
|
40
|
+
amount: BigInt('100000000'), // 100 USDC (6 decimals)
|
|
41
|
+
beneficiary: '0x' + '4'.repeat(40),
|
|
42
|
+
released: false
|
|
43
|
+
}),
|
|
44
|
+
allowance: jest.fn().mockResolvedValue(BigInt(0)),
|
|
45
|
+
balanceOf: jest.fn().mockResolvedValue(BigInt('1000000000')), // 1000 USDC
|
|
46
|
+
approve: jest.fn().mockResolvedValue({
|
|
47
|
+
wait: jest.fn().mockResolvedValue({})
|
|
48
|
+
}),
|
|
49
|
+
// ethers v6 requires getFunction
|
|
50
|
+
getFunction: jest.fn((name: string) => {
|
|
51
|
+
const functions: any = {
|
|
52
|
+
disburse: mockContract.disburse,
|
|
53
|
+
approve: mockContract.approve,
|
|
54
|
+
estimateGas: jest.fn().mockResolvedValue(BigInt(100000))
|
|
55
|
+
};
|
|
56
|
+
const func = functions[name] || jest.fn();
|
|
57
|
+
const estimateGasMap: any = mockContract.estimateGas;
|
|
58
|
+
func.estimateGas = estimateGasMap[name] || jest.fn().mockResolvedValue(BigInt(100000));
|
|
59
|
+
return func;
|
|
60
|
+
}),
|
|
61
|
+
// ethers v6: interface.parseLog for event parsing
|
|
62
|
+
interface: {
|
|
63
|
+
parseLog: jest.fn((_log: any) => ({
|
|
64
|
+
name: 'EscrowCreated',
|
|
65
|
+
args: { escrowId: '0x' + '1'.repeat(64) }
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Mock signer
|
|
71
|
+
const mockSigner = {
|
|
72
|
+
provider: {},
|
|
73
|
+
getAddress: jest.fn().mockResolvedValue('0x' + 'e'.repeat(40))
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Mock Contract constructor
|
|
77
|
+
jest.mock('ethers', () => {
|
|
78
|
+
const actual = jest.requireActual('ethers');
|
|
79
|
+
return {
|
|
80
|
+
...actual,
|
|
81
|
+
Contract: jest.fn().mockImplementation(() => mockContract)
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('EscrowVault - Fund Flow Integrity', () => {
|
|
86
|
+
let escrowVault: EscrowVault;
|
|
87
|
+
|
|
88
|
+
const ESCROW_ADDRESS = '0x' + 'a'.repeat(40);
|
|
89
|
+
const TOKEN_ADDRESS = '0x' + 'c'.repeat(40);
|
|
90
|
+
const BENEFICIARY_ADDRESS = '0x' + 'd'.repeat(40);
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
jest.clearAllMocks();
|
|
94
|
+
escrowVault = new EscrowVault(ESCROW_ADDRESS, mockSigner as any);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('approveToken - Token Approval Safety', () => {
|
|
98
|
+
// NOTE: These tests cover PRE-escrow workflow (USDC approval).
|
|
99
|
+
// Escrow linking atomicity (approve → Kernel.linkEscrow → state transition)
|
|
100
|
+
// is tested in ACTPKernel integration tests.
|
|
101
|
+
|
|
102
|
+
it('should successfully approve token with valid parameters', async () => {
|
|
103
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
104
|
+
|
|
105
|
+
expect(mockContract.approve).toHaveBeenCalledWith(
|
|
106
|
+
ESCROW_ADDRESS,
|
|
107
|
+
BigInt('100000000'),
|
|
108
|
+
expect.objectContaining({
|
|
109
|
+
gasLimit: expect.any(BigInt)
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should reject approval with zero address as token', async () => {
|
|
115
|
+
await expect(
|
|
116
|
+
escrowVault.approveToken('0x0000000000000000000000000000000000000000', BigInt('100000000'))
|
|
117
|
+
).rejects.toThrow('zero address');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should reject approval with invalid token address format', async () => {
|
|
121
|
+
await expect(
|
|
122
|
+
escrowVault.approveToken('invalid-address', BigInt('100000000'))
|
|
123
|
+
).rejects.toThrow('Invalid Ethereum address');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should reject approval with zero amount', async () => {
|
|
127
|
+
await expect(
|
|
128
|
+
escrowVault.approveToken(TOKEN_ADDRESS, BigInt(0))
|
|
129
|
+
).rejects.toThrow('Invalid amount');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should reject approval with negative amount', async () => {
|
|
133
|
+
await expect(
|
|
134
|
+
escrowVault.approveToken(TOKEN_ADDRESS, BigInt(-1))
|
|
135
|
+
).rejects.toThrow('Invalid amount');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should handle minimum USDC amount (0.05 USDC = 50000 wei)', async () => {
|
|
139
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt(50000));
|
|
140
|
+
|
|
141
|
+
expect(mockContract.approve).toHaveBeenCalledWith(
|
|
142
|
+
ESCROW_ADDRESS,
|
|
143
|
+
BigInt(50000), // 0.05 USDC minimum
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
gasLimit: expect.any(BigInt)
|
|
146
|
+
})
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should handle large amounts without overflow', async () => {
|
|
151
|
+
// Should not throw overflow error
|
|
152
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('1000000000000'));
|
|
153
|
+
|
|
154
|
+
expect(mockContract.approve).toHaveBeenCalledWith(
|
|
155
|
+
ESCROW_ADDRESS,
|
|
156
|
+
BigInt('1000000000000'), // 1M USDC
|
|
157
|
+
expect.objectContaining({
|
|
158
|
+
gasLimit: expect.any(BigInt)
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should check current allowance before approving', async () => {
|
|
164
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
165
|
+
|
|
166
|
+
// Should have checked allowance
|
|
167
|
+
expect(mockContract.allowance).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should skip approval if current allowance is sufficient', async () => {
|
|
171
|
+
// Mock sufficient allowance
|
|
172
|
+
mockContract.allowance.mockResolvedValueOnce(BigInt('200000000'));
|
|
173
|
+
|
|
174
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
175
|
+
|
|
176
|
+
// Should NOT call approve if allowance is sufficient
|
|
177
|
+
expect(mockContract.approve).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should wrap transaction revert errors with proper context', async () => {
|
|
181
|
+
mockContract.approve.mockRejectedValueOnce({
|
|
182
|
+
transactionHash: '0x' + 'f'.repeat(64),
|
|
183
|
+
reason: 'Approval failed',
|
|
184
|
+
message: 'execution reverted: Approval failed'
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await expect(
|
|
188
|
+
escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'))
|
|
189
|
+
).rejects.toThrow('Token approval failed');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should skip approval if current allowance exceeds required amount', async () => {
|
|
193
|
+
// Mock allowance greater than required
|
|
194
|
+
mockContract.allowance.mockResolvedValueOnce(BigInt('200000000')); // 200 USDC
|
|
195
|
+
|
|
196
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000')); // Need 100 USDC
|
|
197
|
+
|
|
198
|
+
// Should skip approval entirely
|
|
199
|
+
expect(mockContract.approve).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should use explicit 20% gas buffer for approveToken operation', async () => {
|
|
203
|
+
// This test verifies explicit buffer for approveToken (not default)
|
|
204
|
+
mockContract.allowance.mockResolvedValueOnce(BigInt(0));
|
|
205
|
+
|
|
206
|
+
// estimateGas returns 50000
|
|
207
|
+
mockContract.getFunction('approve').estimateGas.mockResolvedValueOnce(BigInt(50000));
|
|
208
|
+
|
|
209
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
210
|
+
|
|
211
|
+
const approveCall = mockContract.approve.mock.calls[0];
|
|
212
|
+
const gasLimit = approveCall[2].gasLimit;
|
|
213
|
+
|
|
214
|
+
// 50000 * 1.20 = 60000 (approveToken uses explicit 20% buffer)
|
|
215
|
+
expect(gasLimit).toBe(BigInt(60000));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should wrap approval transaction errors with context', async () => {
|
|
219
|
+
mockContract.allowance.mockResolvedValueOnce(BigInt(0));
|
|
220
|
+
|
|
221
|
+
// Mock approve() rejecting with transaction error
|
|
222
|
+
mockContract.approve.mockRejectedValueOnce({
|
|
223
|
+
transactionHash: '0x' + 'f'.repeat(64),
|
|
224
|
+
reason: 'Insufficient funds'
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await expect(
|
|
228
|
+
escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'))
|
|
229
|
+
).rejects.toThrow('Token approval failed: Insufficient funds');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should handle USDC approval returning false instead of reverting', async () => {
|
|
233
|
+
mockContract.allowance.mockResolvedValueOnce(BigInt(0));
|
|
234
|
+
|
|
235
|
+
// Mock approve() returning transaction that fails on wait()
|
|
236
|
+
// This simulates USDC returning false instead of reverting
|
|
237
|
+
mockContract.approve.mockResolvedValueOnce({
|
|
238
|
+
wait: jest.fn().mockRejectedValue(new Error('Transaction failed: status 0'))
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// SDK should detect failed transaction and throw
|
|
242
|
+
await expect(
|
|
243
|
+
escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'))
|
|
244
|
+
).rejects.toThrow('Token approval failed');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('releaseEscrow - Distribution Safety', () => {
|
|
249
|
+
const ESCROW_ID = '0x' + '1'.repeat(64);
|
|
250
|
+
|
|
251
|
+
it('should successfully release escrow to single recipient', async () => {
|
|
252
|
+
const recipients = [BENEFICIARY_ADDRESS];
|
|
253
|
+
const amounts = [BigInt('100000000')];
|
|
254
|
+
|
|
255
|
+
await escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts);
|
|
256
|
+
|
|
257
|
+
expect(mockContract.disburse).toHaveBeenCalledWith(
|
|
258
|
+
ESCROW_ID,
|
|
259
|
+
recipients,
|
|
260
|
+
amounts,
|
|
261
|
+
expect.any(Object)
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should successfully release escrow to multiple recipients', async () => {
|
|
266
|
+
const recipients = [
|
|
267
|
+
BENEFICIARY_ADDRESS,
|
|
268
|
+
'0x' + '5'.repeat(40),
|
|
269
|
+
'0x' + '6'.repeat(40)
|
|
270
|
+
];
|
|
271
|
+
const amounts = [
|
|
272
|
+
BigInt('50000000'), // 50 USDC
|
|
273
|
+
BigInt('30000000'), // 30 USDC
|
|
274
|
+
BigInt('20000000') // 20 USDC
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
await escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts);
|
|
278
|
+
|
|
279
|
+
expect(mockContract.disburse).toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should reject release with mismatched recipients/amounts length', async () => {
|
|
283
|
+
const recipients = [BENEFICIARY_ADDRESS, '0x' + '5'.repeat(40)];
|
|
284
|
+
const amounts = [BigInt('100000000')]; // Only 1 amount for 2 recipients
|
|
285
|
+
|
|
286
|
+
await expect(escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts))
|
|
287
|
+
.rejects.toThrow('length mismatch');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should reject release with empty recipients array', async () => {
|
|
291
|
+
const recipients: string[] = [];
|
|
292
|
+
const amounts: bigint[] = [];
|
|
293
|
+
|
|
294
|
+
await expect(escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts))
|
|
295
|
+
.rejects.toThrow('at least one recipient');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should reject release with zero address recipient', async () => {
|
|
299
|
+
const recipients = ['0x0000000000000000000000000000000000000000'];
|
|
300
|
+
const amounts = [BigInt('100000000')];
|
|
301
|
+
|
|
302
|
+
await expect(escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts))
|
|
303
|
+
.rejects.toThrow('zero address');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should reject release with zero amount', async () => {
|
|
307
|
+
const recipients = [BENEFICIARY_ADDRESS];
|
|
308
|
+
const amounts = [BigInt(0)];
|
|
309
|
+
|
|
310
|
+
await expect(escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts))
|
|
311
|
+
.rejects.toThrow('Invalid amount');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should reject release with negative amount', async () => {
|
|
315
|
+
const recipients = [BENEFICIARY_ADDRESS];
|
|
316
|
+
const amounts = [BigInt(-1)];
|
|
317
|
+
|
|
318
|
+
await expect(escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts))
|
|
319
|
+
.rejects.toThrow('Invalid amount');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should reject release with invalid escrow ID format', async () => {
|
|
323
|
+
const recipients = [BENEFICIARY_ADDRESS];
|
|
324
|
+
const amounts = [BigInt('100000000')];
|
|
325
|
+
|
|
326
|
+
await expect(escrowVault.releaseEscrow('invalid-id', recipients, amounts))
|
|
327
|
+
.rejects.toThrow('Invalid transaction ID format');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should handle disbursement transaction revert', async () => {
|
|
331
|
+
mockContract.disburse.mockRejectedValueOnce({
|
|
332
|
+
transactionHash: '0x' + 'f'.repeat(64),
|
|
333
|
+
reason: 'Escrow already released',
|
|
334
|
+
message: 'execution reverted: Escrow already released'
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const recipients = [BENEFICIARY_ADDRESS];
|
|
338
|
+
const amounts = [BigInt('100000000')];
|
|
339
|
+
|
|
340
|
+
await expect(escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts))
|
|
341
|
+
.rejects.toThrow('Transaction reverted');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('should validate all recipients in array', async () => {
|
|
345
|
+
const recipients = [
|
|
346
|
+
BENEFICIARY_ADDRESS,
|
|
347
|
+
'invalid-address', // Invalid address
|
|
348
|
+
'0x' + '6'.repeat(40)
|
|
349
|
+
];
|
|
350
|
+
const amounts = [
|
|
351
|
+
BigInt('50000000'),
|
|
352
|
+
BigInt('30000000'),
|
|
353
|
+
BigInt('20000000')
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
await expect(escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts))
|
|
357
|
+
.rejects.toThrow('Invalid Ethereum address');
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe('approveToken - USDC Race Condition Mitigation', () => {
|
|
362
|
+
it('should reset approval to zero before setting new value (USDC pattern)', async () => {
|
|
363
|
+
// Mock existing allowance
|
|
364
|
+
mockContract.allowance.mockResolvedValueOnce(BigInt('50000000'));
|
|
365
|
+
|
|
366
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
367
|
+
|
|
368
|
+
// Should approve twice (reset to 0, then set amount)
|
|
369
|
+
expect(mockContract.approve).toHaveBeenCalledTimes(2);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should only approve if current allowance is less than required amount', async () => {
|
|
373
|
+
// Mock allowance exactly equal to amount
|
|
374
|
+
mockContract.allowance.mockResolvedValueOnce(BigInt('100000000'));
|
|
375
|
+
|
|
376
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
377
|
+
|
|
378
|
+
// Should NOT approve if allowance equals amount
|
|
379
|
+
expect(mockContract.approve).not.toHaveBeenCalled();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should estimate gas for both reset and set approval', async () => {
|
|
383
|
+
mockContract.allowance.mockResolvedValueOnce(BigInt('50000000'));
|
|
384
|
+
|
|
385
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
386
|
+
|
|
387
|
+
// Should estimate gas twice (reset + set)
|
|
388
|
+
expect(mockContract.estimateGas.approve).toHaveBeenCalledTimes(2);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('should wait for reset approval before setting new approval', async () => {
|
|
392
|
+
mockContract.allowance.mockResolvedValueOnce(BigInt('50000000'));
|
|
393
|
+
|
|
394
|
+
const waitMock = jest.fn().mockResolvedValue({});
|
|
395
|
+
mockContract.approve.mockResolvedValue({ wait: waitMock });
|
|
396
|
+
|
|
397
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
398
|
+
|
|
399
|
+
// Should wait twice (reset + set)
|
|
400
|
+
expect(waitMock).toHaveBeenCalledTimes(2);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('should include gas settings in approval transactions', async () => {
|
|
404
|
+
const gasSettings = {
|
|
405
|
+
maxFeePerGas: BigInt('2000000000'), // 2 gwei
|
|
406
|
+
maxPriorityFeePerGas: BigInt('1000000000') // 1 gwei
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const vaultWithGas = new EscrowVault(ESCROW_ADDRESS, mockSigner as any, gasSettings);
|
|
410
|
+
|
|
411
|
+
await vaultWithGas.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
412
|
+
|
|
413
|
+
// Should pass gas settings to approval transactions
|
|
414
|
+
expect(mockContract.approve).toHaveBeenCalledWith(
|
|
415
|
+
ESCROW_ADDRESS,
|
|
416
|
+
BigInt('100000000'),
|
|
417
|
+
expect.objectContaining({
|
|
418
|
+
gasLimit: expect.any(BigInt),
|
|
419
|
+
maxFeePerGas: gasSettings.maxFeePerGas,
|
|
420
|
+
maxPriorityFeePerGas: gasSettings.maxPriorityFeePerGas
|
|
421
|
+
})
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('getEscrow - Data Retrieval', () => {
|
|
427
|
+
it('should retrieve escrow details correctly', async () => {
|
|
428
|
+
const ESCROW_ID = '0x' + '1'.repeat(64);
|
|
429
|
+
|
|
430
|
+
const escrow = await escrowVault.getEscrow(ESCROW_ID);
|
|
431
|
+
|
|
432
|
+
expect(escrow.escrowId).toBe(ESCROW_ID);
|
|
433
|
+
expect(escrow.kernel).toBe('0x' + '1'.repeat(40));
|
|
434
|
+
expect(escrow.txId).toBe('0x' + '2'.repeat(64));
|
|
435
|
+
expect(escrow.token).toBe('0x' + '3'.repeat(40));
|
|
436
|
+
expect(escrow.amount).toEqual(BigInt('100000000'));
|
|
437
|
+
expect(escrow.beneficiary).toBe('0x' + '4'.repeat(40));
|
|
438
|
+
expect(escrow.released).toBe(false);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('should get escrow balance', async () => {
|
|
442
|
+
const ESCROW_ID = '0x' + '1'.repeat(64);
|
|
443
|
+
|
|
444
|
+
const balance = await escrowVault.getEscrowBalance(ESCROW_ID);
|
|
445
|
+
|
|
446
|
+
expect(balance).toEqual(BigInt('100000000'));
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe('Token Helper Methods', () => {
|
|
451
|
+
it('should get token balance for account', async () => {
|
|
452
|
+
const USER_ADDRESS = '0x' + 'e'.repeat(40);
|
|
453
|
+
const balance = await escrowVault.getTokenBalance(TOKEN_ADDRESS, USER_ADDRESS);
|
|
454
|
+
|
|
455
|
+
expect(balance).toEqual(BigInt('1000000000'));
|
|
456
|
+
expect(mockContract.balanceOf).toHaveBeenCalledWith(USER_ADDRESS);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('should get token allowance', async () => {
|
|
460
|
+
const USER_ADDRESS = '0x' + 'e'.repeat(40);
|
|
461
|
+
await escrowVault.getTokenAllowance(
|
|
462
|
+
TOKEN_ADDRESS,
|
|
463
|
+
USER_ADDRESS,
|
|
464
|
+
ESCROW_ADDRESS
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
expect(mockContract.allowance).toHaveBeenCalledWith(USER_ADDRESS, ESCROW_ADDRESS);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe('Gas Estimation - V6 Dynamic Buffers', () => {
|
|
472
|
+
it('should apply 20% gas buffer to approveToken', async () => {
|
|
473
|
+
mockContract.estimateGas.approve.mockResolvedValueOnce(BigInt(50000));
|
|
474
|
+
|
|
475
|
+
await escrowVault.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
476
|
+
|
|
477
|
+
// Should call with gasLimit = estimatedGas * 1.2 (20% buffer for ERC20 approval)
|
|
478
|
+
expect(mockContract.approve).toHaveBeenCalledWith(
|
|
479
|
+
ESCROW_ADDRESS,
|
|
480
|
+
BigInt('100000000'),
|
|
481
|
+
expect.objectContaining({
|
|
482
|
+
gasLimit: BigInt(60000) // 50000 * 1.20
|
|
483
|
+
})
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('should apply 30% gas buffer to releaseEscrow', async () => {
|
|
488
|
+
mockContract.estimateGas.disburse.mockResolvedValueOnce(BigInt(80000));
|
|
489
|
+
|
|
490
|
+
const ESCROW_ID = '0x' + '1'.repeat(64);
|
|
491
|
+
const recipients = [BENEFICIARY_ADDRESS];
|
|
492
|
+
const amounts = [BigInt('100000000')];
|
|
493
|
+
|
|
494
|
+
await escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts);
|
|
495
|
+
|
|
496
|
+
expect(mockContract.disburse).toHaveBeenCalledWith(
|
|
497
|
+
ESCROW_ID,
|
|
498
|
+
recipients,
|
|
499
|
+
amounts,
|
|
500
|
+
expect.objectContaining({
|
|
501
|
+
gasLimit: BigInt(104000) // 80000 * 1.30 (30% buffer for multi-recipient disbursement)
|
|
502
|
+
})
|
|
503
|
+
);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('should build transaction options without gas settings', async () => {
|
|
507
|
+
// Create vault without gas settings
|
|
508
|
+
const vaultNoGas = new EscrowVault(ESCROW_ADDRESS, mockSigner as any);
|
|
509
|
+
|
|
510
|
+
mockContract.estimateGas.approve.mockResolvedValueOnce(BigInt(50000));
|
|
511
|
+
|
|
512
|
+
await vaultNoGas.approveToken(TOKEN_ADDRESS, BigInt('100000000'));
|
|
513
|
+
|
|
514
|
+
// Should only include gasLimit, no maxFeePerGas or maxPriorityFeePerGas
|
|
515
|
+
expect(mockContract.approve).toHaveBeenCalledWith(
|
|
516
|
+
ESCROW_ADDRESS,
|
|
517
|
+
BigInt('100000000'),
|
|
518
|
+
expect.objectContaining({
|
|
519
|
+
gasLimit: BigInt(60000) // 50000 * 1.20
|
|
520
|
+
})
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
// Verify gas settings are NOT included
|
|
524
|
+
const approveCall = mockContract.approve.mock.calls[0];
|
|
525
|
+
expect(approveCall[2]).not.toHaveProperty('maxFeePerGas');
|
|
526
|
+
expect(approveCall[2]).not.toHaveProperty('maxPriorityFeePerGas');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('should handle releaseEscrow error without reason field', async () => {
|
|
530
|
+
mockContract.disburse.mockRejectedValueOnce({
|
|
531
|
+
transactionHash: '0x' + 'f'.repeat(64),
|
|
532
|
+
message: 'Transaction failed'
|
|
533
|
+
// No 'reason' field
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const ESCROW_ID = '0x' + '1'.repeat(64);
|
|
537
|
+
const recipients = [BENEFICIARY_ADDRESS];
|
|
538
|
+
const amounts = [BigInt('100000000')];
|
|
539
|
+
|
|
540
|
+
await expect(escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts))
|
|
541
|
+
.rejects.toThrow('Transaction failed');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('should use correct buffer for releaseEscrow operation (30%)', async () => {
|
|
545
|
+
const ESCROW_ID = '0x' + '1'.repeat(64);
|
|
546
|
+
const recipients = [BENEFICIARY_ADDRESS, '0x' + '5'.repeat(40)];
|
|
547
|
+
const amounts = [BigInt('60000000'), BigInt('40000000')];
|
|
548
|
+
|
|
549
|
+
mockContract.estimateGas.disburse.mockResolvedValueOnce(BigInt(100000));
|
|
550
|
+
|
|
551
|
+
await escrowVault.releaseEscrow(ESCROW_ID, recipients, amounts);
|
|
552
|
+
|
|
553
|
+
// Should use 30% buffer for releaseEscrow (multi-recipient complexity)
|
|
554
|
+
expect(mockContract.disburse).toHaveBeenCalledWith(
|
|
555
|
+
ESCROW_ID,
|
|
556
|
+
recipients,
|
|
557
|
+
amounts,
|
|
558
|
+
expect.objectContaining({
|
|
559
|
+
gasLimit: BigInt(130000) // 100000 * 1.30
|
|
560
|
+
})
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe('getAddress', () => {
|
|
566
|
+
it('should return escrow vault address', () => {
|
|
567
|
+
expect(escrowVault.getAddress()).toBe(ESCROW_ADDRESS);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
});
|