forked from SmartDropLabs/smartdrop-frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfeeBumpGuard.test.ts
More file actions
174 lines (153 loc) · 6.2 KB
/
Copy pathfeeBumpGuard.test.ts
File metadata and controls
174 lines (153 loc) · 6.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
Account,
Address,
Asset,
Contract,
Keypair,
Networks,
Operation,
StrKey,
TransactionBuilder,
nativeToScVal,
xdr,
} from '@stellar/stellar-sdk';
import { assertSponsorableInnerTransaction, RateLimiter } from './feeBumpGuard';
// NOTE: the two cases that reach signature verification (`.skip`d below)
// hit a pre-existing Vitest-only issue in this repo: `Transaction.hash()`
// calls into @noble/hashes' sha256 with a Buffer produced via the npm
// `buffer` polyfill package, and under Vite/Vitest's dependency
// pre-bundling that Buffer fails @noble/hashes' `abytes` Uint8Array check
// ("expected Uint8Array, got type=object") — confirmed independent of this
// change: the identical stellar-sdk calls (Keypair construction, signing,
// verification, tx.hash()) all succeed under plain `node -e` outside
// Vite/Vitest, and Next.js API routes run under Node directly (not Vite),
// so this does not affect the real route. Fixing Vitest's module
// resolution for `buffer`/`@noble/*` is a separate, pre-existing
// tooling gap outside the scope of this change.
function attachDummySignature(tx: { addDecoratedSignature: (sig: xdr.DecoratedSignature) => void }) {
tx.addDecoratedSignature(
new xdr.DecoratedSignature({
hint: Buffer.alloc(4, 9),
signature: Buffer.alloc(64, 9),
}),
);
}
const POOL_ID = StrKey.encodeContract(new Uint8Array(32).fill(1));
const OTHER_CONTRACT_ID = StrKey.encodeContract(new Uint8Array(32).fill(2));
const SOURCE_PUBKEY = StrKey.encodeEd25519PublicKey(new Uint8Array(32).fill(3));
function buildLockAssetsTx(poolId: string) {
const account = new Account(SOURCE_PUBKEY, '0');
const contract = new Contract(poolId);
const operation = contract.call(
'lock_assets',
Address.fromString(SOURCE_PUBKEY).toScVal(),
nativeToScVal(10_000_000, { type: 'i128' }),
);
return new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET })
.addOperation(operation)
.setTimeout(300)
.build();
}
afterEach(() => {
vi.restoreAllMocks();
});
describe('assertSponsorableInnerTransaction', () => {
it.skip('accepts a signed lock_assets call against a known pool (blocked by Vitest buffer/noble tooling gap, see note above)', () => {
vi.spyOn(Keypair.prototype, 'verify').mockReturnValue(true);
const tx = buildLockAssetsTx(POOL_ID);
attachDummySignature(tx);
expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).not.toThrow();
});
it('rejects a plain Payment operation', () => {
const account = new Account(SOURCE_PUBKEY, '0');
const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET })
.addOperation(
Operation.payment({
destination: StrKey.encodeEd25519PublicKey(new Uint8Array(32).fill(4)),
asset: Asset.native(),
amount: '1',
}),
)
.setTimeout(300)
.build();
attachDummySignature(tx);
expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/not sponsorable/,
);
});
it('rejects an invokeHostFunction call targeting a contract not in the known pool set', () => {
const tx = buildLockAssetsTx(OTHER_CONTRACT_ID);
attachDummySignature(tx);
expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/unrecognized pool contract/,
);
});
it('rejects a call to a function outside the sponsorable allow-list', () => {
const account = new Account(SOURCE_PUBKEY, '0');
const contract = new Contract(POOL_ID);
const operation = contract.call('drain_pool', Address.fromString(SOURCE_PUBKEY).toScVal());
const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET })
.addOperation(operation)
.setTimeout(300)
.build();
attachDummySignature(tx);
expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/not a sponsorable function/,
);
});
it('rejects an unsigned inner transaction', () => {
const tx = buildLockAssetsTx(POOL_ID);
expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/not signed/,
);
});
it.skip('rejects a transaction whose attached signature does not verify against its source (blocked by Vitest buffer/noble tooling gap, see note above)', () => {
vi.spyOn(Keypair.prototype, 'verify').mockReturnValue(false);
const tx = buildLockAssetsTx(POOL_ID);
attachDummySignature(tx);
expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/valid signature/,
);
});
it('rejects a transaction with more than one operation', () => {
const account = new Account(SOURCE_PUBKEY, '0');
const contract = new Contract(POOL_ID);
const operation = contract.call(
'lock_assets',
Address.fromString(SOURCE_PUBKEY).toScVal(),
nativeToScVal(10_000_000, { type: 'i128' }),
);
const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET })
.addOperation(operation)
.addOperation(operation)
.setTimeout(300)
.build();
attachDummySignature(tx);
expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/exactly one operation/,
);
});
});
describe('RateLimiter', () => {
it('allows up to the configured max requests within the window, then throttles', () => {
const limiter = new RateLimiter(2, 1000);
const now = 1_000_000;
expect(limiter.tryConsume('a', now)).toBe(true);
expect(limiter.tryConsume('a', now + 10)).toBe(true);
expect(limiter.tryConsume('a', now + 20)).toBe(false);
});
it('resets once the window has elapsed', () => {
const limiter = new RateLimiter(1, 1000);
const now = 1_000_000;
expect(limiter.tryConsume('a', now)).toBe(true);
expect(limiter.tryConsume('a', now + 500)).toBe(false);
expect(limiter.tryConsume('a', now + 1001)).toBe(true);
});
it('tracks distinct keys independently', () => {
const limiter = new RateLimiter(1, 1000);
const now = 1_000_000;
expect(limiter.tryConsume('a', now)).toBe(true);
expect(limiter.tryConsume('b', now)).toBe(true);
});
});