intents-1click-rule-engine 1.0.0
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 +473 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/src/matcher.d.ts +15 -0
- package/dist/src/matcher.d.ts.map +1 -0
- package/dist/src/matcher.js +110 -0
- package/dist/src/rule-engine.d.ts +21 -0
- package/dist/src/rule-engine.d.ts.map +1 -0
- package/dist/src/rule-engine.js +94 -0
- package/dist/src/token-registry.d.ts +20 -0
- package/dist/src/token-registry.d.ts.map +1 -0
- package/dist/src/token-registry.js +78 -0
- package/dist/src/types.d.ts +76 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +0 -0
- package/dist/src/validator.d.ts +11 -0
- package/dist/src/validator.d.ts.map +1 -0
- package/dist/src/validator.js +175 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
# intents-1click-rule-engine
|
|
2
|
+
|
|
3
|
+
Fee configuration rule engine for NEAR Intents 1Click API. Matches swap requests against configurable rules to determine fees.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add intents-1click-rule-engine
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { RuleEngine, getTotalBps, calculateFee, type FeeConfig } from "intents-1click-rule-engine";
|
|
15
|
+
|
|
16
|
+
const feeConfig: FeeConfig = {
|
|
17
|
+
version: "1.0.0",
|
|
18
|
+
default_fee: { type: "bps", bps: 20, recipient: "fees.near" },
|
|
19
|
+
rules: [
|
|
20
|
+
{
|
|
21
|
+
id: "usdc-swaps",
|
|
22
|
+
enabled: true,
|
|
23
|
+
priority: 100,
|
|
24
|
+
match: {
|
|
25
|
+
in: { symbol: "USDC", blockchain: "*" },
|
|
26
|
+
out: { symbol: "USDC", blockchain: "*" },
|
|
27
|
+
},
|
|
28
|
+
fee: { type: "bps", bps: 10, recipient: "fees.near" },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Create engine (validates config, throws on error)
|
|
34
|
+
const engine = new RuleEngine(feeConfig);
|
|
35
|
+
|
|
36
|
+
// Initialize token registry
|
|
37
|
+
await engine.initialize();
|
|
38
|
+
|
|
39
|
+
// Match a swap request
|
|
40
|
+
const result = engine.match({
|
|
41
|
+
originAsset: "nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near",
|
|
42
|
+
destinationAsset: "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
console.log(result.matched); // true
|
|
46
|
+
console.log(result.fee); // { type: "bps", bps: 10, recipient: "fees.near" }
|
|
47
|
+
console.log(result.rule?.id); // "usdc-swaps"
|
|
48
|
+
console.log(getTotalBps(result.fee)); // 10
|
|
49
|
+
|
|
50
|
+
// Calculate fee amount
|
|
51
|
+
const feeAmount = calculateFee("1000000", getTotalBps(result.fee)); // "1000" (0.10% of 1000000)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Fee Calculation
|
|
55
|
+
|
|
56
|
+
The `calculateFee` and `calculateAmountAfterFee` functions validate their inputs:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// Valid usage
|
|
60
|
+
calculateFee("1000000", 20); // "2000" (0.20% of 1000000)
|
|
61
|
+
calculateFee(1000000n, 20); // "2000" (bigint input also works)
|
|
62
|
+
calculateAmountAfterFee("1000000", 20); // "998000" (amount minus fee)
|
|
63
|
+
|
|
64
|
+
// Input validation - these throw descriptive errors:
|
|
65
|
+
calculateFee("abc", 20); // Error: not a valid integer string
|
|
66
|
+
calculateFee("12.5", 20); // Error: not a valid integer string
|
|
67
|
+
calculateFee("-100", 20); // Error: must be non-negative
|
|
68
|
+
calculateFee("1000", -5); // Error: bps must be non-negative
|
|
69
|
+
calculateFee("1000", 10001); // Error: bps exceeds maximum of 10000 (100%)
|
|
70
|
+
calculateFee("1000", 20.5); // Error: bps must be an integer
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The `bps` parameter must be between 0 and 10000 (0% to 100%).
|
|
74
|
+
|
|
75
|
+
## Token Registry
|
|
76
|
+
|
|
77
|
+
The engine fetches the token list from `https://1click.chaindefuser.com/v0/tokens` to resolve asset IDs to their `blockchain` and `symbol`. This allows rules to match by symbol/blockchain instead of exact asset IDs.
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
Swap Request Token Registry Lookup
|
|
81
|
+
───────────── ─────────────────────
|
|
82
|
+
originAsset: "nep141:eth-..." → { blockchain: "eth", symbol: "USDC" }
|
|
83
|
+
destinationAsset: "nep141:sol-..." → { blockchain: "sol", symbol: "USDC" }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Call `engine.initialize()` before matching to fetch the token list. The list is cached for 1 hour by default.
|
|
87
|
+
|
|
88
|
+
## Rule Matching
|
|
89
|
+
|
|
90
|
+
Rules are evaluated by priority (highest first). The first matching rule wins. If no rules match, `default_fee` is used.
|
|
91
|
+
|
|
92
|
+
Each rule can match on:
|
|
93
|
+
|
|
94
|
+
- `assetId` - exact asset identifier (string or array)
|
|
95
|
+
- `blockchain` - chain identifier (e.g., `"eth"`, `"polygon"`)
|
|
96
|
+
- `symbol` - token symbol (e.g., `"USDC"`, `"WBTC"`)
|
|
97
|
+
|
|
98
|
+
Special patterns:
|
|
99
|
+
- `"*"` - wildcard, matches any value
|
|
100
|
+
- `"!value"` - negation, matches anything except `value`
|
|
101
|
+
- `["a", "b"]` - array, matches any value in the list (OR logic)
|
|
102
|
+
|
|
103
|
+
**Important:** All pattern matching is **case-sensitive**. `"USDC"` will not match `"usdc"` or `"Usdc"`. Ensure your rules use the exact casing from the token registry.
|
|
104
|
+
|
|
105
|
+
## Fee Structure
|
|
106
|
+
|
|
107
|
+
Each fee requires a `type`, `bps`, and `recipient`. Fees can be a single object or an array for multiple recipients:
|
|
108
|
+
|
|
109
|
+
### Single fee
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
fee: { type: "bps", bps: 20, recipient: "fees.near" }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Multiple fees (split between recipients)
|
|
116
|
+
|
|
117
|
+
Each recipient can have their own fee amount:
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
fee: [
|
|
121
|
+
{ type: "bps", bps: 14, recipient: "fees.near" }, // 0.14%
|
|
122
|
+
{ type: "bps", bps: 6, recipient: "partner.near" }, // 0.06%
|
|
123
|
+
]
|
|
124
|
+
// Total: 0.20%
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Rule Examples
|
|
128
|
+
|
|
129
|
+
### Match by symbol (any chain)
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
{
|
|
133
|
+
id: "usdc-to-usdc",
|
|
134
|
+
enabled: true,
|
|
135
|
+
priority: 100,
|
|
136
|
+
match: {
|
|
137
|
+
in: { symbol: "USDC" },
|
|
138
|
+
out: { symbol: "USDC" },
|
|
139
|
+
},
|
|
140
|
+
fee: { type: "bps", bps: 10, recipient: "fees.near" },
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Match by blockchain route
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
{
|
|
148
|
+
id: "eth-to-polygon",
|
|
149
|
+
enabled: true,
|
|
150
|
+
priority: 100,
|
|
151
|
+
match: {
|
|
152
|
+
in: { blockchain: "eth" },
|
|
153
|
+
out: { blockchain: "polygon" },
|
|
154
|
+
},
|
|
155
|
+
fee: { type: "bps", bps: 15, recipient: "fees.near" },
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Match exact asset pair
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
{
|
|
163
|
+
id: "eth-usdc-to-polygon-usdc",
|
|
164
|
+
enabled: true,
|
|
165
|
+
priority: 200,
|
|
166
|
+
match: {
|
|
167
|
+
in: { assetId: "nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near" },
|
|
168
|
+
out: { assetId: "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near" },
|
|
169
|
+
},
|
|
170
|
+
fee: { type: "bps", bps: 5, recipient: "fees.near" },
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Mixed matching (blockchain + symbol)
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
{
|
|
178
|
+
id: "eth-usdc-to-any-usdc",
|
|
179
|
+
enabled: true,
|
|
180
|
+
priority: 150,
|
|
181
|
+
match: {
|
|
182
|
+
in: { blockchain: "eth", symbol: "USDC" },
|
|
183
|
+
out: { symbol: "USDC", blockchain: "*" },
|
|
184
|
+
},
|
|
185
|
+
fee: { type: "bps", bps: 8, recipient: "fees.near" },
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Wildcard for any token on specific chain
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
{
|
|
193
|
+
id: "anything-to-solana",
|
|
194
|
+
enabled: true,
|
|
195
|
+
priority: 50,
|
|
196
|
+
match: {
|
|
197
|
+
in: { blockchain: "*" },
|
|
198
|
+
out: { blockchain: "sol" },
|
|
199
|
+
},
|
|
200
|
+
fee: { type: "bps", bps: 25, recipient: "fees.near" },
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Negation (exclude specific values)
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
{
|
|
208
|
+
id: "non-eth-swaps",
|
|
209
|
+
enabled: true,
|
|
210
|
+
priority: 100,
|
|
211
|
+
match: {
|
|
212
|
+
in: { blockchain: "!eth" },
|
|
213
|
+
out: { blockchain: "!eth" },
|
|
214
|
+
},
|
|
215
|
+
fee: { type: "bps", bps: 12, recipient: "fees.near" },
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Array values (match multiple options)
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
{
|
|
223
|
+
id: "stablecoin-swaps",
|
|
224
|
+
enabled: true,
|
|
225
|
+
priority: 100,
|
|
226
|
+
match: {
|
|
227
|
+
in: { symbol: ["USDC", "USDT", "DAI"] },
|
|
228
|
+
out: { symbol: ["USDC", "USDT", "DAI"] },
|
|
229
|
+
},
|
|
230
|
+
fee: { type: "bps", bps: 5, recipient: "fees.near" },
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
{
|
|
236
|
+
id: "l2-to-l2",
|
|
237
|
+
enabled: true,
|
|
238
|
+
priority: 100,
|
|
239
|
+
match: {
|
|
240
|
+
in: { blockchain: ["arb", "polygon", "base", "op"] },
|
|
241
|
+
out: { blockchain: ["arb", "polygon", "base", "op"] },
|
|
242
|
+
},
|
|
243
|
+
fee: { type: "bps", bps: 8, recipient: "fees.near" },
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Array values for assetId
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
{
|
|
251
|
+
id: "specific-usdc-tokens",
|
|
252
|
+
enabled: true,
|
|
253
|
+
priority: 150,
|
|
254
|
+
match: {
|
|
255
|
+
in: {
|
|
256
|
+
assetId: [
|
|
257
|
+
"nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near",
|
|
258
|
+
"nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near",
|
|
259
|
+
]
|
|
260
|
+
},
|
|
261
|
+
out: { symbol: "USDC" },
|
|
262
|
+
},
|
|
263
|
+
fee: { type: "bps", bps: 5, recipient: "fees.near" },
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Time-based rules (promotional periods)
|
|
268
|
+
|
|
269
|
+
Rules can have `valid_from` and `valid_until` timestamps. Dates must be valid ISO 8601 strings - invalid dates will throw an error during matching.
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
{
|
|
273
|
+
id: "new-year-promo",
|
|
274
|
+
enabled: true,
|
|
275
|
+
priority: 200,
|
|
276
|
+
valid_from: "2025-01-01T00:00:00Z",
|
|
277
|
+
valid_until: "2025-01-07T23:59:59Z",
|
|
278
|
+
match: {
|
|
279
|
+
in: { symbol: "*" },
|
|
280
|
+
out: { symbol: "*" },
|
|
281
|
+
},
|
|
282
|
+
fee: { type: "bps", bps: 0, recipient: "fees.near" },
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
{
|
|
288
|
+
id: "summer-discount",
|
|
289
|
+
enabled: true,
|
|
290
|
+
priority: 150,
|
|
291
|
+
valid_from: "2025-06-01T00:00:00Z",
|
|
292
|
+
// No valid_until - runs indefinitely after start
|
|
293
|
+
match: {
|
|
294
|
+
in: { symbol: "USDC" },
|
|
295
|
+
out: { symbol: "USDC" },
|
|
296
|
+
},
|
|
297
|
+
fee: { type: "bps", bps: 5, recipient: "fees.near" },
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Multiple fee recipients
|
|
302
|
+
|
|
303
|
+
Split fees between multiple accounts (e.g., platform + partner):
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
{
|
|
307
|
+
id: "partner-referral",
|
|
308
|
+
enabled: true,
|
|
309
|
+
priority: 100,
|
|
310
|
+
match: {
|
|
311
|
+
in: { symbol: "USDC" },
|
|
312
|
+
out: { symbol: "USDC" },
|
|
313
|
+
},
|
|
314
|
+
fee: [
|
|
315
|
+
{ type: "bps", bps: 7, recipient: "fees.near" },
|
|
316
|
+
{ type: "bps", bps: 3, recipient: "partner.near" },
|
|
317
|
+
],
|
|
318
|
+
}
|
|
319
|
+
// Total fee: 10 bps (0.10%), split 70/30
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Complete config example
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
const feeConfig = {
|
|
326
|
+
version: "1.0.0",
|
|
327
|
+
default_fee: { type: "bps", bps: 30, recipient: "fees.near" },
|
|
328
|
+
rules: [
|
|
329
|
+
// Most specific first (higher priority)
|
|
330
|
+
{
|
|
331
|
+
id: "eth-usdc-to-polygon-usdc",
|
|
332
|
+
enabled: true,
|
|
333
|
+
priority: 200,
|
|
334
|
+
match: {
|
|
335
|
+
in: { assetId: "nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near" },
|
|
336
|
+
out: { assetId: "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near" },
|
|
337
|
+
},
|
|
338
|
+
fee: { type: "bps", bps: 5, recipient: "fees.near" },
|
|
339
|
+
},
|
|
340
|
+
// Chain + symbol combo
|
|
341
|
+
{
|
|
342
|
+
id: "eth-usdc-to-any",
|
|
343
|
+
enabled: true,
|
|
344
|
+
priority: 150,
|
|
345
|
+
match: {
|
|
346
|
+
in: { blockchain: "eth", symbol: "USDC" },
|
|
347
|
+
out: { blockchain: "*" },
|
|
348
|
+
},
|
|
349
|
+
fee: { type: "bps", bps: 12, recipient: "fees.near" },
|
|
350
|
+
},
|
|
351
|
+
// All stablecoin swaps
|
|
352
|
+
{
|
|
353
|
+
id: "usdc-swaps",
|
|
354
|
+
enabled: true,
|
|
355
|
+
priority: 100,
|
|
356
|
+
match: {
|
|
357
|
+
in: { symbol: "USDC" },
|
|
358
|
+
out: { symbol: "USDC" },
|
|
359
|
+
},
|
|
360
|
+
fee: { type: "bps", bps: 10, recipient: "fees.near" },
|
|
361
|
+
},
|
|
362
|
+
// Disabled rule (won't match)
|
|
363
|
+
{
|
|
364
|
+
id: "promo-free-swaps",
|
|
365
|
+
enabled: false,
|
|
366
|
+
priority: 300,
|
|
367
|
+
match: {
|
|
368
|
+
in: { blockchain: "*" },
|
|
369
|
+
out: { blockchain: "*" },
|
|
370
|
+
},
|
|
371
|
+
fee: { type: "bps", bps: 0, recipient: "fees.near" },
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
};
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Priority ordering
|
|
378
|
+
|
|
379
|
+
- Higher priority rules are evaluated first
|
|
380
|
+
- First matching rule wins
|
|
381
|
+
- Use priority gaps (50, 100, 150, 200) to allow inserting rules later
|
|
382
|
+
- More specific rules should have higher priority
|
|
383
|
+
|
|
384
|
+
## Rule Builder UI
|
|
385
|
+
|
|
386
|
+
A simple browser-based tool for building fee configurations is included:
|
|
387
|
+
|
|
388
|
+
**Online:** https://bonnevoyager.github.io/intents-1click-rule-engine/
|
|
389
|
+
|
|
390
|
+
**Local:**
|
|
391
|
+
```bash
|
|
392
|
+
open index.html
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Features:
|
|
396
|
+
- Visual rule editor with all matching options
|
|
397
|
+
- Support for wildcards, negation, and arrays
|
|
398
|
+
- Multiple fee recipients
|
|
399
|
+
- Time-based rules
|
|
400
|
+
- Import/export JSON configurations
|
|
401
|
+
- No dependencies - works offline
|
|
402
|
+
|
|
403
|
+
## Development
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
bun install
|
|
407
|
+
bun test
|
|
408
|
+
bun run typecheck
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
## Publishing to npm
|
|
412
|
+
|
|
413
|
+
### Prerequisites
|
|
414
|
+
|
|
415
|
+
1. Create an [npm account](https://www.npmjs.com/signup) if you don't have one
|
|
416
|
+
2. Login to npm:
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
npm login
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Publish
|
|
423
|
+
|
|
424
|
+
1. Update the version in `package.json`:
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
# Patch release (1.0.0 -> 1.0.1)
|
|
428
|
+
npm version patch
|
|
429
|
+
|
|
430
|
+
# Minor release (1.0.0 -> 1.1.0)
|
|
431
|
+
npm version minor
|
|
432
|
+
|
|
433
|
+
# Major release (1.0.0 -> 2.0.0)
|
|
434
|
+
npm version major
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
2. Run tests to ensure everything works:
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
bun test && bun run typecheck
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
3. Publish to npm:
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
npm publish
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Publishing a scoped package
|
|
450
|
+
|
|
451
|
+
If you want to publish under a scope (e.g., `@myorg/intents-1click-rule-engine`):
|
|
452
|
+
|
|
453
|
+
1. Update the `name` in `package.json`:
|
|
454
|
+
|
|
455
|
+
```json
|
|
456
|
+
{
|
|
457
|
+
"name": "@myorg/intents-1click-rule-engine"
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
2. Publish with public access (scoped packages are private by default):
|
|
462
|
+
|
|
463
|
+
```bash
|
|
464
|
+
npm publish --access public
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### Verify publication
|
|
468
|
+
|
|
469
|
+
After publishing, verify the package is available:
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
npm info intents-1click-rule-engine
|
|
473
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FeeConfig, MatchResult, SwapRequest, TokenRegistry } from "./types";
|
|
2
|
+
export declare class RuleMatcher {
|
|
3
|
+
private rules;
|
|
4
|
+
private defaultFee;
|
|
5
|
+
private tokenRegistry;
|
|
6
|
+
constructor(config: FeeConfig, tokenRegistry: TokenRegistry);
|
|
7
|
+
private sortRulesByPriority;
|
|
8
|
+
private matchesSinglePattern;
|
|
9
|
+
private matchesValue;
|
|
10
|
+
private matchesToken;
|
|
11
|
+
private parseDate;
|
|
12
|
+
private isRuleValidNow;
|
|
13
|
+
match(request: SwapRequest): MatchResult;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=matcher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"matcher.d.ts","sourceRoot":"","sources":["../../src/matcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,SAAS,EAA2C,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAEjI,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,UAAU,CAA2B;IAC7C,OAAO,CAAC,aAAa,CAAgB;gBAEzB,MAAM,EAAE,SAAS,EAAE,aAAa,EAAE,aAAa;IAM3D,OAAO,CAAC,mBAAmB;IAQ3B,OAAO,CAAC,oBAAoB;IAQ5B,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,YAAY;IAyBpB,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,cAAc;IAatB,KAAK,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW;CAuCzC"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export class RuleMatcher {
|
|
2
|
+
rules;
|
|
3
|
+
defaultFee;
|
|
4
|
+
tokenRegistry;
|
|
5
|
+
constructor(config, tokenRegistry) {
|
|
6
|
+
this.defaultFee = config.default_fee;
|
|
7
|
+
this.rules = this.sortRulesByPriority(config.rules);
|
|
8
|
+
this.tokenRegistry = tokenRegistry;
|
|
9
|
+
}
|
|
10
|
+
sortRulesByPriority(rules) {
|
|
11
|
+
return [...rules].sort((a, b) => {
|
|
12
|
+
const priorityA = a.priority ?? 100;
|
|
13
|
+
const priorityB = b.priority ?? 100;
|
|
14
|
+
return priorityB - priorityA;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
matchesSinglePattern(pattern, value) {
|
|
18
|
+
if (pattern === "*")
|
|
19
|
+
return true;
|
|
20
|
+
if (pattern.startsWith("!")) {
|
|
21
|
+
return value !== pattern.slice(1);
|
|
22
|
+
}
|
|
23
|
+
return pattern === value;
|
|
24
|
+
}
|
|
25
|
+
matchesValue(pattern, value) {
|
|
26
|
+
if (Array.isArray(pattern)) {
|
|
27
|
+
return pattern.some((p) => this.matchesSinglePattern(p, value));
|
|
28
|
+
}
|
|
29
|
+
return this.matchesSinglePattern(pattern, value);
|
|
30
|
+
}
|
|
31
|
+
matchesToken(matcher, token) {
|
|
32
|
+
const matchedBy = {};
|
|
33
|
+
if (matcher.assetId) {
|
|
34
|
+
if (!this.matchesValue(matcher.assetId, token.assetId)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
matchedBy.assetId = true;
|
|
38
|
+
}
|
|
39
|
+
if (matcher.blockchain) {
|
|
40
|
+
if (!this.matchesValue(matcher.blockchain, token.blockchain)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
matchedBy.blockchain = true;
|
|
44
|
+
}
|
|
45
|
+
if (matcher.symbol) {
|
|
46
|
+
if (!this.matchesValue(matcher.symbol, token.symbol)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
matchedBy.symbol = true;
|
|
50
|
+
}
|
|
51
|
+
return { token, matchedBy };
|
|
52
|
+
}
|
|
53
|
+
parseDate(dateStr, fieldName) {
|
|
54
|
+
const timestamp = new Date(dateStr).getTime();
|
|
55
|
+
if (Number.isNaN(timestamp)) {
|
|
56
|
+
throw new Error(`Invalid ${fieldName}: "${dateStr}" is not a valid date string`);
|
|
57
|
+
}
|
|
58
|
+
return timestamp;
|
|
59
|
+
}
|
|
60
|
+
isRuleValidNow(rule) {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (rule.valid_from) {
|
|
63
|
+
const from = this.parseDate(rule.valid_from, "valid_from");
|
|
64
|
+
if (now < from)
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (rule.valid_until) {
|
|
68
|
+
const until = this.parseDate(rule.valid_until, "valid_until");
|
|
69
|
+
if (now > until)
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
match(request) {
|
|
75
|
+
const originToken = this.tokenRegistry.getToken(request.originAsset);
|
|
76
|
+
const destinationToken = this.tokenRegistry.getToken(request.destinationAsset);
|
|
77
|
+
if (!originToken || !destinationToken) {
|
|
78
|
+
return {
|
|
79
|
+
matched: false,
|
|
80
|
+
fee: this.defaultFee,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
for (const rule of this.rules) {
|
|
84
|
+
if (!rule.enabled)
|
|
85
|
+
continue;
|
|
86
|
+
if (!this.isRuleValidNow(rule))
|
|
87
|
+
continue;
|
|
88
|
+
const inMatchInfo = this.matchesToken(rule.match.in, originToken);
|
|
89
|
+
const outMatchInfo = this.matchesToken(rule.match.out, destinationToken);
|
|
90
|
+
if (inMatchInfo && outMatchInfo) {
|
|
91
|
+
return {
|
|
92
|
+
matched: true,
|
|
93
|
+
rule,
|
|
94
|
+
fee: rule.fee,
|
|
95
|
+
matchDetails: {
|
|
96
|
+
originToken,
|
|
97
|
+
destinationToken,
|
|
98
|
+
in: inMatchInfo,
|
|
99
|
+
out: outMatchInfo,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
matched: false,
|
|
106
|
+
fee: this.defaultFee,
|
|
107
|
+
matchDetails: { originToken, destinationToken },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Fee, FeeConfig, SwapRequest, MatchResult } from "./types";
|
|
2
|
+
export declare function getTotalBps(fee: Fee | Fee[]): number;
|
|
3
|
+
export declare function calculateFee(amount: string | bigint, bps: number): string;
|
|
4
|
+
export declare function calculateAmountAfterFee(amount: string | bigint, bps: number): string;
|
|
5
|
+
export interface RuleEngineOptions {
|
|
6
|
+
tokenRegistryUrl?: string;
|
|
7
|
+
tokenRegistryCacheTtlMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class RuleEngine {
|
|
10
|
+
private matcher;
|
|
11
|
+
private tokenRegistry;
|
|
12
|
+
private feeConfig;
|
|
13
|
+
constructor(feeConfig: FeeConfig, options?: RuleEngineOptions);
|
|
14
|
+
initialize(): Promise<void>;
|
|
15
|
+
ensureReady(): Promise<void>;
|
|
16
|
+
match(request: SwapRequest): MatchResult;
|
|
17
|
+
matchWithRefresh(request: SwapRequest): Promise<MatchResult>;
|
|
18
|
+
getTokenRegistrySize(): number;
|
|
19
|
+
getFeeConfig(): FeeConfig;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=rule-engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rule-engine.d.ts","sourceRoot":"","sources":["../../src/rule-engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAUxE,wBAAgB,WAAW,CAAC,GAAG,EAAE,GAAG,GAAG,GAAG,EAAE,GAAG,MAAM,CAKpD;AAqCD,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAKzE;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAKpF;AAED,MAAM,WAAW,iBAAiB;IAChC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,aAAa,CAAsB;IAC3C,OAAO,CAAC,SAAS,CAAY;gBAEjB,SAAS,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,iBAAiB;IAcvD,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAIlC,KAAK,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW;IAIlC,gBAAgB,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;IAKlE,oBAAoB,IAAI,MAAM;IAI9B,YAAY,IAAI,SAAS;CAG1B"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { RuleMatcher } from "./matcher";
|
|
2
|
+
import { CachedTokenRegistry } from "./token-registry";
|
|
3
|
+
import { validateConfig } from "./validator";
|
|
4
|
+
const DEFAULT_TOKEN_REGISTRY_URL = "https://1click.chaindefuser.com/v0/tokens";
|
|
5
|
+
const DEFAULT_CACHE_TTL_MS = 3600000; // 1 hour
|
|
6
|
+
const BPS_DIVISOR = 10000n;
|
|
7
|
+
const MAX_BPS = 10000; // 100% fee cap
|
|
8
|
+
export function getTotalBps(fee) {
|
|
9
|
+
if (Array.isArray(fee)) {
|
|
10
|
+
return fee.reduce((sum, f) => sum + f.bps, 0);
|
|
11
|
+
}
|
|
12
|
+
return fee.bps;
|
|
13
|
+
}
|
|
14
|
+
function parseAmount(amount) {
|
|
15
|
+
if (typeof amount === "bigint") {
|
|
16
|
+
return amount;
|
|
17
|
+
}
|
|
18
|
+
if (typeof amount !== "string") {
|
|
19
|
+
throw new Error(`Invalid amount: expected string or bigint, got ${typeof amount}`);
|
|
20
|
+
}
|
|
21
|
+
if (amount.trim() === "") {
|
|
22
|
+
throw new Error("Invalid amount: empty string");
|
|
23
|
+
}
|
|
24
|
+
if (!/^-?\d+$/.test(amount)) {
|
|
25
|
+
throw new Error(`Invalid amount: "${amount}" is not a valid integer string`);
|
|
26
|
+
}
|
|
27
|
+
const result = BigInt(amount);
|
|
28
|
+
if (result < 0n) {
|
|
29
|
+
throw new Error(`Invalid amount: "${amount}" must be non-negative`);
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
function validateBps(bps) {
|
|
34
|
+
if (typeof bps !== "number" || !Number.isFinite(bps)) {
|
|
35
|
+
throw new Error(`Invalid bps: expected a finite number, got ${bps}`);
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isInteger(bps)) {
|
|
38
|
+
throw new Error(`Invalid bps: ${bps} must be an integer`);
|
|
39
|
+
}
|
|
40
|
+
if (bps < 0) {
|
|
41
|
+
throw new Error(`Invalid bps: ${bps} must be non-negative`);
|
|
42
|
+
}
|
|
43
|
+
if (bps > MAX_BPS) {
|
|
44
|
+
throw new Error(`Invalid bps: ${bps} exceeds maximum of ${MAX_BPS} (100%)`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function calculateFee(amount, bps) {
|
|
48
|
+
const amountBigInt = parseAmount(amount);
|
|
49
|
+
validateBps(bps);
|
|
50
|
+
const fee = (amountBigInt * BigInt(bps)) / BPS_DIVISOR;
|
|
51
|
+
return fee.toString();
|
|
52
|
+
}
|
|
53
|
+
export function calculateAmountAfterFee(amount, bps) {
|
|
54
|
+
const amountBigInt = parseAmount(amount);
|
|
55
|
+
validateBps(bps);
|
|
56
|
+
const fee = BigInt(calculateFee(amountBigInt, bps));
|
|
57
|
+
return (amountBigInt - fee).toString();
|
|
58
|
+
}
|
|
59
|
+
export class RuleEngine {
|
|
60
|
+
matcher;
|
|
61
|
+
tokenRegistry;
|
|
62
|
+
feeConfig;
|
|
63
|
+
constructor(feeConfig, options) {
|
|
64
|
+
const validation = validateConfig(feeConfig);
|
|
65
|
+
if (!validation.valid) {
|
|
66
|
+
throw new Error(`Invalid fee config: ${validation.errors.map((e) => `${e.path}: ${e.message}`).join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
this.feeConfig = feeConfig;
|
|
69
|
+
this.tokenRegistry = new CachedTokenRegistry({
|
|
70
|
+
url: options?.tokenRegistryUrl ?? DEFAULT_TOKEN_REGISTRY_URL,
|
|
71
|
+
cacheTtlMs: options?.tokenRegistryCacheTtlMs ?? DEFAULT_CACHE_TTL_MS,
|
|
72
|
+
});
|
|
73
|
+
this.matcher = new RuleMatcher(this.feeConfig, this.tokenRegistry);
|
|
74
|
+
}
|
|
75
|
+
async initialize() {
|
|
76
|
+
await this.tokenRegistry.refresh();
|
|
77
|
+
}
|
|
78
|
+
async ensureReady() {
|
|
79
|
+
await this.tokenRegistry.ensureFresh();
|
|
80
|
+
}
|
|
81
|
+
match(request) {
|
|
82
|
+
return this.matcher.match(request);
|
|
83
|
+
}
|
|
84
|
+
async matchWithRefresh(request) {
|
|
85
|
+
await this.ensureReady();
|
|
86
|
+
return this.match(request);
|
|
87
|
+
}
|
|
88
|
+
getTokenRegistrySize() {
|
|
89
|
+
return this.tokenRegistry.size;
|
|
90
|
+
}
|
|
91
|
+
getFeeConfig() {
|
|
92
|
+
return this.feeConfig;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TokenInfo, TokenRegistry } from "./types";
|
|
2
|
+
export interface TokenRegistryConfig {
|
|
3
|
+
url: string;
|
|
4
|
+
cacheTtlMs: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class CachedTokenRegistry implements TokenRegistry {
|
|
7
|
+
private config;
|
|
8
|
+
private cache;
|
|
9
|
+
private lastFetchTime;
|
|
10
|
+
private refreshPromise;
|
|
11
|
+
constructor(config: TokenRegistryConfig);
|
|
12
|
+
private isCacheValid;
|
|
13
|
+
refresh(): Promise<void>;
|
|
14
|
+
private doRefresh;
|
|
15
|
+
ensureFresh(): Promise<void>;
|
|
16
|
+
getToken(assetId: string): TokenInfo | undefined;
|
|
17
|
+
getAllTokens(): TokenInfo[];
|
|
18
|
+
get size(): number;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=token-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-registry.d.ts","sourceRoot":"","sources":["../../src/token-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAExD,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;CACpB;AAuBD,qBAAa,mBAAoB,YAAW,aAAa;IACvD,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,KAAK,CAAqC;IAClD,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,cAAc,CAA8B;gBAExC,MAAM,EAAE,mBAAmB;IAIvC,OAAO,CAAC,YAAY;IAId,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YAchB,SAAS;IAoCjB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAMlC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAIhD,YAAY,IAAI,SAAS,EAAE;IAI3B,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
function isValidTokenResponse(token) {
|
|
2
|
+
if (typeof token !== "object" || token === null)
|
|
3
|
+
return false;
|
|
4
|
+
const t = token;
|
|
5
|
+
return (typeof t.assetId === "string" &&
|
|
6
|
+
typeof t.blockchain === "string" &&
|
|
7
|
+
typeof t.symbol === "string" &&
|
|
8
|
+
typeof t.decimals === "number");
|
|
9
|
+
}
|
|
10
|
+
export class CachedTokenRegistry {
|
|
11
|
+
config;
|
|
12
|
+
cache = new Map();
|
|
13
|
+
lastFetchTime = 0;
|
|
14
|
+
refreshPromise = null;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
}
|
|
18
|
+
isCacheValid() {
|
|
19
|
+
return Date.now() - this.lastFetchTime < this.config.cacheTtlMs;
|
|
20
|
+
}
|
|
21
|
+
async refresh() {
|
|
22
|
+
// If a refresh is already in progress, return the existing promise
|
|
23
|
+
if (this.refreshPromise) {
|
|
24
|
+
return this.refreshPromise;
|
|
25
|
+
}
|
|
26
|
+
this.refreshPromise = this.doRefresh();
|
|
27
|
+
try {
|
|
28
|
+
await this.refreshPromise;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
this.refreshPromise = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async doRefresh() {
|
|
35
|
+
const response = await fetch(this.config.url);
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`Failed to fetch tokens: ${response.status} ${response.statusText}`);
|
|
38
|
+
}
|
|
39
|
+
const data = await response.json();
|
|
40
|
+
if (!Array.isArray(data)) {
|
|
41
|
+
throw new Error(`Invalid token registry response: expected array, got ${typeof data}`);
|
|
42
|
+
}
|
|
43
|
+
const newCache = new Map();
|
|
44
|
+
const invalidTokens = [];
|
|
45
|
+
for (let i = 0; i < data.length; i++) {
|
|
46
|
+
const token = data[i];
|
|
47
|
+
if (!isValidTokenResponse(token)) {
|
|
48
|
+
invalidTokens.push(i);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
newCache.set(token.assetId, {
|
|
52
|
+
assetId: token.assetId,
|
|
53
|
+
blockchain: token.blockchain,
|
|
54
|
+
symbol: token.symbol,
|
|
55
|
+
decimals: token.decimals,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (invalidTokens.length > 0 && newCache.size === 0) {
|
|
59
|
+
throw new Error(`Invalid token registry response: all ${data.length} tokens failed validation`);
|
|
60
|
+
}
|
|
61
|
+
this.cache = newCache;
|
|
62
|
+
this.lastFetchTime = Date.now();
|
|
63
|
+
}
|
|
64
|
+
async ensureFresh() {
|
|
65
|
+
if (!this.isCacheValid()) {
|
|
66
|
+
await this.refresh();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
getToken(assetId) {
|
|
70
|
+
return this.cache.get(assetId);
|
|
71
|
+
}
|
|
72
|
+
getAllTokens() {
|
|
73
|
+
return Array.from(this.cache.values());
|
|
74
|
+
}
|
|
75
|
+
get size() {
|
|
76
|
+
return this.cache.size;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export interface TokenInfo {
|
|
2
|
+
assetId: string;
|
|
3
|
+
blockchain: string;
|
|
4
|
+
symbol: string;
|
|
5
|
+
decimals: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Matcher for filtering tokens by blockchain, symbol, or assetId.
|
|
9
|
+
*
|
|
10
|
+
* **Important: All string matching is case-sensitive.**
|
|
11
|
+
* - `symbol: "USDC"` will NOT match a token with symbol `"usdc"` or `"Usdc"`
|
|
12
|
+
* - `blockchain: "eth"` will NOT match `"ETH"` or `"Eth"`
|
|
13
|
+
*
|
|
14
|
+
* Supported patterns:
|
|
15
|
+
* - Exact match: `"eth"` matches only `"eth"`
|
|
16
|
+
* - Wildcard: `"*"` matches any value
|
|
17
|
+
* - Negation: `"!eth"` matches any value except `"eth"`
|
|
18
|
+
* - Array (OR logic): `["eth", "base"]` matches `"eth"` or `"base"`
|
|
19
|
+
*/
|
|
20
|
+
export interface TokenMatcher {
|
|
21
|
+
blockchain?: string | string[];
|
|
22
|
+
symbol?: string | string[];
|
|
23
|
+
assetId?: string | string[];
|
|
24
|
+
}
|
|
25
|
+
export interface RuleMatch {
|
|
26
|
+
in: TokenMatcher;
|
|
27
|
+
out: TokenMatcher;
|
|
28
|
+
}
|
|
29
|
+
export interface Fee {
|
|
30
|
+
type: "bps";
|
|
31
|
+
bps: number;
|
|
32
|
+
recipient: string;
|
|
33
|
+
}
|
|
34
|
+
export interface Rule {
|
|
35
|
+
id: string;
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
priority?: number;
|
|
38
|
+
description?: string;
|
|
39
|
+
match: RuleMatch;
|
|
40
|
+
fee: Fee | Fee[];
|
|
41
|
+
valid_from?: string;
|
|
42
|
+
valid_until?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface FeeConfig {
|
|
45
|
+
version: string;
|
|
46
|
+
default_fee: Fee | Fee[];
|
|
47
|
+
rules: Rule[];
|
|
48
|
+
}
|
|
49
|
+
export interface SwapRequest {
|
|
50
|
+
originAsset: string;
|
|
51
|
+
destinationAsset: string;
|
|
52
|
+
amount?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface TokenMatchInfo {
|
|
55
|
+
token: TokenInfo;
|
|
56
|
+
matchedBy: {
|
|
57
|
+
assetId?: boolean;
|
|
58
|
+
blockchain?: boolean;
|
|
59
|
+
symbol?: boolean;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export interface MatchResult {
|
|
63
|
+
matched: boolean;
|
|
64
|
+
rule?: Rule;
|
|
65
|
+
fee: Fee | Fee[];
|
|
66
|
+
matchDetails?: {
|
|
67
|
+
originToken: TokenInfo;
|
|
68
|
+
destinationToken: TokenInfo;
|
|
69
|
+
in?: TokenMatchInfo;
|
|
70
|
+
out?: TokenMatchInfo;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export interface TokenRegistry {
|
|
74
|
+
getToken(assetId: string): TokenInfo | undefined;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,YAAY;IAC3B,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC/B,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,YAAY,CAAC;IACjB,GAAG,EAAE,YAAY,CAAC;CACnB;AAED,MAAM,WAAW,GAAG;IAClB,IAAI,EAAE,KAAK,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,SAAS,CAAC;IACjB,GAAG,EAAE,GAAG,GAAG,GAAG,EAAE,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,GAAG,GAAG,GAAG,EAAE,CAAC;IACzB,KAAK,EAAE,IAAI,EAAE,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE;QACT,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,UAAU,CAAC,EAAE,OAAO,CAAC;QACrB,MAAM,CAAC,EAAE,OAAO,CAAC;KAClB,CAAC;CACH;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,GAAG,EAAE,GAAG,GAAG,GAAG,EAAE,CAAC;IACjB,YAAY,CAAC,EAAE;QACb,WAAW,EAAE,SAAS,CAAC;QACvB,gBAAgB,EAAE,SAAS,CAAC;QAC5B,EAAE,CAAC,EAAE,cAAc,CAAC;QACpB,GAAG,CAAC,EAAE,cAAc,CAAC;KACtB,CAAC;CACH;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAAC;CAClD"}
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FeeConfig } from "./types";
|
|
2
|
+
export interface ValidationError {
|
|
3
|
+
path: string;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ValidationResult {
|
|
7
|
+
valid: boolean;
|
|
8
|
+
errors: ValidationError[];
|
|
9
|
+
}
|
|
10
|
+
export declare function validateConfig(config: FeeConfig): ValidationResult;
|
|
11
|
+
//# sourceMappingURL=validator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../../src/validator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAA2B,MAAM,SAAS,CAAC;AAElE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAwDD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAuGD,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,gBAAgB,CAkClE"}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const NEAR_ACCOUNT_REGEX = /^(?:[a-z\d]+[-_])*[a-z\d]+(?:\.[a-z\d]+[-_]*[a-z\d]+)*$/;
|
|
2
|
+
const NEAR_IMPLICIT_ACCOUNT_REGEX = /^[a-f0-9]{64}$/;
|
|
3
|
+
const MAX_BPS = 10000; // 100% fee cap
|
|
4
|
+
function isValidNearAccount(account) {
|
|
5
|
+
if (account.length < 2 || account.length > 64)
|
|
6
|
+
return false;
|
|
7
|
+
return NEAR_ACCOUNT_REGEX.test(account) || NEAR_IMPLICIT_ACCOUNT_REGEX.test(account);
|
|
8
|
+
}
|
|
9
|
+
function isValidDateString(dateStr) {
|
|
10
|
+
const timestamp = new Date(dateStr).getTime();
|
|
11
|
+
return !Number.isNaN(timestamp);
|
|
12
|
+
}
|
|
13
|
+
function validateSingleFee(fee, path) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
if (fee.type !== "bps") {
|
|
16
|
+
errors.push({ path: `${path}.type`, message: "fee.type must be 'bps'" });
|
|
17
|
+
}
|
|
18
|
+
if (typeof fee.bps !== "number" || fee.bps < 0) {
|
|
19
|
+
errors.push({ path: `${path}.bps`, message: "fee.bps must be a non-negative number" });
|
|
20
|
+
}
|
|
21
|
+
else if (fee.bps > MAX_BPS) {
|
|
22
|
+
errors.push({ path: `${path}.bps`, message: `fee.bps exceeds maximum of ${MAX_BPS} (100%)` });
|
|
23
|
+
}
|
|
24
|
+
if (!fee.recipient || typeof fee.recipient !== "string") {
|
|
25
|
+
errors.push({ path: `${path}.recipient`, message: "fee.recipient is required and must be a string" });
|
|
26
|
+
}
|
|
27
|
+
else if (!isValidNearAccount(fee.recipient)) {
|
|
28
|
+
errors.push({ path: `${path}.recipient`, message: "fee.recipient must be a valid NEAR account" });
|
|
29
|
+
}
|
|
30
|
+
return errors;
|
|
31
|
+
}
|
|
32
|
+
function validateFee(fee, path) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
if (Array.isArray(fee)) {
|
|
35
|
+
if (fee.length === 0) {
|
|
36
|
+
errors.push({ path, message: "fee array must not be empty" });
|
|
37
|
+
return errors;
|
|
38
|
+
}
|
|
39
|
+
for (let i = 0; i < fee.length; i++) {
|
|
40
|
+
errors.push(...validateSingleFee(fee[i], `${path}[${i}]`));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (fee && typeof fee === "object") {
|
|
44
|
+
errors.push(...validateSingleFee(fee, path));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
errors.push({ path, message: "fee is required" });
|
|
48
|
+
}
|
|
49
|
+
return errors;
|
|
50
|
+
}
|
|
51
|
+
function isNonEmptyStringOrArray(value) {
|
|
52
|
+
if (typeof value === "string")
|
|
53
|
+
return value.length > 0;
|
|
54
|
+
if (Array.isArray(value))
|
|
55
|
+
return value.length > 0 && value.every((v) => typeof v === "string" && v.length > 0);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
function validateTokenMatcher(matcher, path) {
|
|
59
|
+
const errors = [];
|
|
60
|
+
// Check for empty arrays
|
|
61
|
+
if (Array.isArray(matcher.blockchain) && matcher.blockchain.length === 0) {
|
|
62
|
+
errors.push({ path: `${path}.blockchain`, message: "blockchain array must not be empty" });
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(matcher.symbol) && matcher.symbol.length === 0) {
|
|
65
|
+
errors.push({ path: `${path}.symbol`, message: "symbol array must not be empty" });
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(matcher.assetId) && matcher.assetId.length === 0) {
|
|
68
|
+
errors.push({ path: `${path}.assetId`, message: "assetId array must not be empty" });
|
|
69
|
+
}
|
|
70
|
+
// Check for empty strings in arrays
|
|
71
|
+
if (Array.isArray(matcher.blockchain) && matcher.blockchain.some((v) => v === "")) {
|
|
72
|
+
errors.push({ path: `${path}.blockchain`, message: "blockchain array must not contain empty strings" });
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(matcher.symbol) && matcher.symbol.some((v) => v === "")) {
|
|
75
|
+
errors.push({ path: `${path}.symbol`, message: "symbol array must not contain empty strings" });
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(matcher.assetId) && matcher.assetId.some((v) => v === "")) {
|
|
78
|
+
errors.push({ path: `${path}.assetId`, message: "assetId array must not contain empty strings" });
|
|
79
|
+
}
|
|
80
|
+
const hasIdentifier = isNonEmptyStringOrArray(matcher.blockchain) ||
|
|
81
|
+
isNonEmptyStringOrArray(matcher.symbol) ||
|
|
82
|
+
isNonEmptyStringOrArray(matcher.assetId);
|
|
83
|
+
if (!hasIdentifier) {
|
|
84
|
+
errors.push({
|
|
85
|
+
path,
|
|
86
|
+
message: "At least one of blockchain, symbol, or assetId must be defined",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return errors;
|
|
90
|
+
}
|
|
91
|
+
function validateRule(rule, index) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
const path = `rules[${index}]`;
|
|
94
|
+
if (!rule.id || typeof rule.id !== "string") {
|
|
95
|
+
errors.push({ path: `${path}.id`, message: "id is required and must be a string" });
|
|
96
|
+
}
|
|
97
|
+
if (typeof rule.enabled !== "boolean") {
|
|
98
|
+
errors.push({ path: `${path}.enabled`, message: "enabled is required and must be a boolean" });
|
|
99
|
+
}
|
|
100
|
+
if (rule.priority !== undefined && (typeof rule.priority !== "number" || rule.priority < 0)) {
|
|
101
|
+
errors.push({ path: `${path}.priority`, message: "priority must be a non-negative number" });
|
|
102
|
+
}
|
|
103
|
+
if (!rule.match) {
|
|
104
|
+
errors.push({ path: `${path}.match`, message: "match is required" });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
if (!rule.match.in) {
|
|
108
|
+
errors.push({ path: `${path}.match.in`, message: "match.in is required" });
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
errors.push(...validateTokenMatcher(rule.match.in, `${path}.match.in`));
|
|
112
|
+
}
|
|
113
|
+
if (!rule.match.out) {
|
|
114
|
+
errors.push({ path: `${path}.match.out`, message: "match.out is required" });
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
errors.push(...validateTokenMatcher(rule.match.out, `${path}.match.out`));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!rule.fee) {
|
|
121
|
+
errors.push({ path: `${path}.fee`, message: "fee is required" });
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
errors.push(...validateFee(rule.fee, `${path}.fee`));
|
|
125
|
+
}
|
|
126
|
+
if (rule.valid_from !== undefined) {
|
|
127
|
+
if (typeof rule.valid_from !== "string") {
|
|
128
|
+
errors.push({ path: `${path}.valid_from`, message: "valid_from must be a string" });
|
|
129
|
+
}
|
|
130
|
+
else if (!isValidDateString(rule.valid_from)) {
|
|
131
|
+
errors.push({ path: `${path}.valid_from`, message: `valid_from "${rule.valid_from}" is not a valid date string` });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (rule.valid_until !== undefined) {
|
|
135
|
+
if (typeof rule.valid_until !== "string") {
|
|
136
|
+
errors.push({ path: `${path}.valid_until`, message: "valid_until must be a string" });
|
|
137
|
+
}
|
|
138
|
+
else if (!isValidDateString(rule.valid_until)) {
|
|
139
|
+
errors.push({ path: `${path}.valid_until`, message: `valid_until "${rule.valid_until}" is not a valid date string` });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return errors;
|
|
143
|
+
}
|
|
144
|
+
export function validateConfig(config) {
|
|
145
|
+
const errors = [];
|
|
146
|
+
if (!config.version || typeof config.version !== "string") {
|
|
147
|
+
errors.push({ path: "version", message: "version is required and must be a string" });
|
|
148
|
+
}
|
|
149
|
+
if (!config.default_fee) {
|
|
150
|
+
errors.push({ path: "default_fee", message: "default_fee is required" });
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
errors.push(...validateFee(config.default_fee, "default_fee"));
|
|
154
|
+
}
|
|
155
|
+
if (!Array.isArray(config.rules)) {
|
|
156
|
+
errors.push({ path: "rules", message: "rules must be an array" });
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const ruleIds = new Set();
|
|
160
|
+
for (let i = 0; i < config.rules.length; i++) {
|
|
161
|
+
const rule = config.rules[i];
|
|
162
|
+
errors.push(...validateRule(rule, i));
|
|
163
|
+
if (rule.id) {
|
|
164
|
+
if (ruleIds.has(rule.id)) {
|
|
165
|
+
errors.push({ path: `rules[${i}].id`, message: `Duplicate rule id: ${rule.id}` });
|
|
166
|
+
}
|
|
167
|
+
ruleIds.add(rule.id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
valid: errors.length === 0,
|
|
173
|
+
errors,
|
|
174
|
+
};
|
|
175
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "intents-1click-rule-engine",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fee configuration rule engine for NEAR Intents 1Click API",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.build.json",
|
|
20
|
+
"prepublishOnly": "bun run build",
|
|
21
|
+
"test": "bun test",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"near",
|
|
26
|
+
"intents",
|
|
27
|
+
"1click",
|
|
28
|
+
"fee",
|
|
29
|
+
"rules",
|
|
30
|
+
"swap"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/bun": "latest",
|
|
35
|
+
"typescript": "^5"
|
|
36
|
+
}
|
|
37
|
+
}
|