@vultisig/rujira 12.0.0 → 12.1.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/CHANGELOG.md +43 -0
- package/dist/ccl/base.d.ts +17 -0
- package/dist/ccl/base.d.ts.map +1 -0
- package/dist/ccl/base.js +60 -0
- package/dist/ccl/ccl.d.ts +5 -0
- package/dist/ccl/ccl.d.ts.map +1 -0
- package/dist/ccl/ccl.js +73 -0
- package/dist/ccl/ccl.test.d.ts +2 -0
- package/dist/ccl/ccl.test.d.ts.map +1 -0
- package/dist/ccl/ccl.test.js +281 -0
- package/dist/ccl/index.d.ts +6 -0
- package/dist/ccl/index.d.ts.map +1 -0
- package/dist/ccl/index.js +5 -0
- package/dist/ccl/linear.d.ts +12 -0
- package/dist/ccl/linear.d.ts.map +1 -0
- package/dist/ccl/linear.js +47 -0
- package/dist/ccl/quadratic.d.ts +13 -0
- package/dist/ccl/quadratic.d.ts.map +1 -0
- package/dist/ccl/quadratic.js +52 -0
- package/dist/ccl/types.d.ts +29 -0
- package/dist/ccl/types.d.ts.map +1 -0
- package/dist/ccl/types.js +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -0
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/modules/range.d.ts +167 -0
- package/dist/modules/range.d.ts.map +1 -0
- package/dist/modules/range.js +467 -0
- package/package.json +11 -3
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { RujiraError, RujiraErrorCode, wrapError } from '../errors.js';
|
|
2
|
+
import { base64Encode } from '../utils/encoding.js';
|
|
3
|
+
import { validateThorAddress } from '../validation/address-validator.js';
|
|
4
|
+
const RUJIRA_GRAPHQL_URL = 'https://api.vultisig.com/ruji/api/graphql';
|
|
5
|
+
const GRAPHQL_TIMEOUT_MS = 15_000;
|
|
6
|
+
/** Scale of MOIC and DPI fields in analytics (divide raw bigint by this). */
|
|
7
|
+
export const RANGE_MOIC_SCALE = 1e12;
|
|
8
|
+
/** Scale of APR field in analytics (divide raw bigint by this). */
|
|
9
|
+
export const RANGE_APR_SCALE = 1e10;
|
|
10
|
+
/** Fractional digits used by config Decimal fields (high/low/spread/skew/fee). */
|
|
11
|
+
export const RANGE_CONFIG_DECIMALS = 12;
|
|
12
|
+
/** Fractional digits used by the withdraw share parameter. */
|
|
13
|
+
export const RANGE_WITHDRAW_SHARE_DECIMALS = 4;
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Validation helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const DECIMAL_12_RE = /^-?\d+(\.\d{1,12})?$/;
|
|
18
|
+
const DECIMAL_4_RE = /^\d+(\.\d{1,4})?$/;
|
|
19
|
+
const IDX_RE = /^\d+$/;
|
|
20
|
+
function assertDecimal12(label, v) {
|
|
21
|
+
if (typeof v !== 'string' || !DECIMAL_12_RE.test(v)) {
|
|
22
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `${label} must be a Decimal string with up to 12 fractional digits (got ${JSON.stringify(v)})`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function assertDecimal4(label, v) {
|
|
26
|
+
if (typeof v !== 'string' || !DECIMAL_4_RE.test(v)) {
|
|
27
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `${label} must be a Decimal string with up to 4 fractional digits (got ${JSON.stringify(v)})`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function assertIdx(v) {
|
|
31
|
+
if (typeof v !== 'string' || !IDX_RE.test(v)) {
|
|
32
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `idx must be a non-negative integer string (got ${JSON.stringify(v)})`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function assertCoin(label, c) {
|
|
36
|
+
if (!c || typeof c.denom !== 'string' || !c.denom || typeof c.amount !== 'string' || !/^\d+$/.test(c.amount)) {
|
|
37
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `${label} must be a Coin with string denom and integer-string amount`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const RANGE_CONFIG_SCALE = 10n ** BigInt(RANGE_CONFIG_DECIMALS);
|
|
41
|
+
const RANGE_WITHDRAW_SHARE_SCALE = 10n ** BigInt(RANGE_WITHDRAW_SHARE_DECIMALS);
|
|
42
|
+
// Scale a validated Decimal-string (matched by DECIMAL_12_RE / DECIMAL_4_RE) to a BigInt
|
|
43
|
+
// with `decimals` fractional digits. Avoids parseFloat to preserve precision.
|
|
44
|
+
function decimalToScaled(v, decimals) {
|
|
45
|
+
const scale = 10n ** BigInt(decimals);
|
|
46
|
+
const sign = v.startsWith('-') ? -1n : 1n;
|
|
47
|
+
const unsigned = v.startsWith('-') ? v.slice(1) : v;
|
|
48
|
+
const [whole, fraction = ''] = unsigned.split('.');
|
|
49
|
+
const paddedFraction = fraction.padEnd(decimals, '0').slice(0, decimals);
|
|
50
|
+
return sign * (BigInt(whole || '0') * scale + BigInt(paddedFraction || '0'));
|
|
51
|
+
}
|
|
52
|
+
function assertShare(v) {
|
|
53
|
+
assertDecimal4('share', v);
|
|
54
|
+
// 0 < share <= 1, compared in scaled BigInt space (reject 0 and >1).
|
|
55
|
+
const scaled = decimalToScaled(v, RANGE_WITHDRAW_SHARE_DECIMALS);
|
|
56
|
+
if (!(scaled > 0n && scaled <= RANGE_WITHDRAW_SHARE_SCALE)) {
|
|
57
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `share must be in (0, 1] (got ${v})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function assertPairAddress(a) {
|
|
61
|
+
if (typeof a !== 'string') {
|
|
62
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `pairAddress must be a bech32 thor1... contract address (got ${JSON.stringify(a)})`);
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
validateThorAddress(a);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `pairAddress must be a bech32 thor1... contract address (got ${JSON.stringify(a)})`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function assertConfig(c) {
|
|
72
|
+
assertDecimal12('config.high', c.high);
|
|
73
|
+
assertDecimal12('config.low', c.low);
|
|
74
|
+
assertDecimal12('config.spread', c.spread);
|
|
75
|
+
assertDecimal12('config.skew', c.skew);
|
|
76
|
+
assertDecimal12('config.fee', c.fee);
|
|
77
|
+
// Scaled BigInt comparisons — avoids parseFloat so 12dp precision is preserved.
|
|
78
|
+
const high = decimalToScaled(c.high, RANGE_CONFIG_DECIMALS);
|
|
79
|
+
const low = decimalToScaled(c.low, RANGE_CONFIG_DECIMALS);
|
|
80
|
+
const spread = decimalToScaled(c.spread, RANGE_CONFIG_DECIMALS);
|
|
81
|
+
const fee = decimalToScaled(c.fee, RANGE_CONFIG_DECIMALS);
|
|
82
|
+
if (low <= 0n) {
|
|
83
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `config.low (${c.low}) must be > 0`);
|
|
84
|
+
}
|
|
85
|
+
if (high <= low) {
|
|
86
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `config.high (${c.high}) must be > config.low (${c.low})`);
|
|
87
|
+
}
|
|
88
|
+
// Spread reasonability: 0 < spread < 1 (contract likely enforces, but catch early).
|
|
89
|
+
if (!(spread > 0n && spread < RANGE_CONFIG_SCALE)) {
|
|
90
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `config.spread must be in (0, 1) (got ${c.spread})`);
|
|
91
|
+
}
|
|
92
|
+
// Fee must not exceed spread.
|
|
93
|
+
if (fee < 0n || fee > spread) {
|
|
94
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `config.fee must be in [0, spread] (got ${c.fee} vs spread ${c.spread})`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function gqlFetch(query, variables) {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timeout = setTimeout(() => controller.abort(), GRAPHQL_TIMEOUT_MS);
|
|
100
|
+
// Wrap the ENTIRE fetch + body read in one try block so the timeout
|
|
101
|
+
// covers both phases. Previously `clearTimeout` ran in `finally` right
|
|
102
|
+
// after `fetch` resolved (headers only), leaving `response.json()` with
|
|
103
|
+
// no deadline. A slow body stream past GRAPHQL_TIMEOUT_MS would hang
|
|
104
|
+
// indefinitely — defeating the guard on the exact case we were trying
|
|
105
|
+
// to protect against.
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetch(RUJIRA_GRAPHQL_URL, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Type': 'application/json' },
|
|
110
|
+
body: JSON.stringify({ query, variables }),
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
});
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new RujiraError(RujiraErrorCode.NETWORK_ERROR, `GraphQL request failed: ${response.status}`);
|
|
115
|
+
}
|
|
116
|
+
const json = (await response.json());
|
|
117
|
+
if (json.errors?.length) {
|
|
118
|
+
throw new RujiraError(RujiraErrorCode.NETWORK_ERROR, `GraphQL errors: ${json.errors[0].message}`);
|
|
119
|
+
}
|
|
120
|
+
// Review finding: a malformed backend response (200 OK, no `errors`,
|
|
121
|
+
// no `data`) would return undefined and downstream callers would
|
|
122
|
+
// collapse it to "no positions" / "no pair" via their `?? []` / `??
|
|
123
|
+
// null` fallbacks — masking a real backend/schema break as a silent
|
|
124
|
+
// empty result. Users would make fund-moving decisions on false
|
|
125
|
+
// negatives. Reject at the transport boundary instead.
|
|
126
|
+
if (json.data === undefined || json.data === null) {
|
|
127
|
+
throw new RujiraError(RujiraErrorCode.NETWORK_ERROR, 'GraphQL response missing `data` field (backend or schema error)');
|
|
128
|
+
}
|
|
129
|
+
return json.data;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
// AbortError from the timeout above would otherwise fall through to wrapError's
|
|
133
|
+
// default NETWORK_ERROR branch (its "timeout"/"timed out" string match misses
|
|
134
|
+
// the "The operation was aborted." message). Translate it to a proper TIMEOUT.
|
|
135
|
+
// Now covers both `fetch` and `response.json()` abort paths.
|
|
136
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
137
|
+
throw new RujiraError(RujiraErrorCode.TIMEOUT, `GraphQL request timed out after ${GRAPHQL_TIMEOUT_MS}ms`, error, true);
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
clearTimeout(timeout);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Query shapes — keep narrow, only fields we surface.
|
|
146
|
+
// NOTE: FinRange + FinPair schema is taken from rujira-ui `TradeSubscriptions`.
|
|
147
|
+
// If upstream schema changes, these queries must be updated in lockstep.
|
|
148
|
+
// Queries follow the schema at api.vultisig.com/ruji/api/graphql as of
|
|
149
|
+
// 2026-04-21. Paths verified via __type introspection:
|
|
150
|
+
// Account.fin.ranges → FinRangeConnection (Relay edges/node)
|
|
151
|
+
// FinRange.{idx,base,quote,feesBase,feesQuote,principalUsd,yieldUsd,
|
|
152
|
+
// high,low,spread,skew,fee,price,valueUsd,pair,analytics}
|
|
153
|
+
// Config fields are FLAT on FinRange (not under a nested config object).
|
|
154
|
+
const POSITIONS_QUERY = `
|
|
155
|
+
query RangePositions($id: ID!) {
|
|
156
|
+
node(id: $id) {
|
|
157
|
+
... on Account {
|
|
158
|
+
fin {
|
|
159
|
+
ranges(first: 100) {
|
|
160
|
+
edges {
|
|
161
|
+
node {
|
|
162
|
+
id idx base quote
|
|
163
|
+
feesBase feesQuote
|
|
164
|
+
principalUsd yieldUsd
|
|
165
|
+
high low spread skew fee price
|
|
166
|
+
pair { id address }
|
|
167
|
+
analytics { moic dpi apr firstDepositDate status }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
`;
|
|
176
|
+
const POSITION_QUERY = `
|
|
177
|
+
query RangePosition($id: ID!) {
|
|
178
|
+
node(id: $id) {
|
|
179
|
+
... on FinRange {
|
|
180
|
+
id idx base quote
|
|
181
|
+
feesBase feesQuote
|
|
182
|
+
principalUsd yieldUsd
|
|
183
|
+
high low spread skew fee price
|
|
184
|
+
pair { id address }
|
|
185
|
+
analytics { moic dpi apr firstDepositDate status }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
`;
|
|
190
|
+
// Pair lookup goes through finV3.pairs sorted by volume. We fetch the top N
|
|
191
|
+
// and do (base, quote) matching client-side on symbols AND denoms — tolerating
|
|
192
|
+
// LLM-mangled inputs like "xruji" (stripped "x/ruji") or "thorrune"
|
|
193
|
+
// (stripped "thor.rune") by normalising separators on both sides.
|
|
194
|
+
const PAIR_QUERY = `
|
|
195
|
+
query FinPairsAll {
|
|
196
|
+
finV3 {
|
|
197
|
+
pairs(first: 200, sortBy: VOLUME, sortDir: DESC) {
|
|
198
|
+
edges {
|
|
199
|
+
node {
|
|
200
|
+
address
|
|
201
|
+
assetBase { metadata { symbol } variants { native { denom } } }
|
|
202
|
+
assetQuote { metadata { symbol } variants { native { denom } } }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
`;
|
|
209
|
+
function mapRange(r) {
|
|
210
|
+
if (!r.pair?.address) {
|
|
211
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `FinRange ${r.idx} is missing pair.address (partial/error GraphQL response?)`);
|
|
212
|
+
}
|
|
213
|
+
const config = r.high && r.low && r.spread && r.skew !== undefined && r.fee !== undefined
|
|
214
|
+
? { high: r.high, low: r.low, spread: r.spread, skew: r.skew, fee: r.fee }
|
|
215
|
+
: undefined;
|
|
216
|
+
return {
|
|
217
|
+
idx: r.idx,
|
|
218
|
+
pairAddress: r.pair.address,
|
|
219
|
+
base: r.base,
|
|
220
|
+
quote: r.quote,
|
|
221
|
+
feesBase: r.feesBase,
|
|
222
|
+
feesQuote: r.feesQuote,
|
|
223
|
+
principalUsd: r.principalUsd,
|
|
224
|
+
yieldUsd: r.yieldUsd,
|
|
225
|
+
config,
|
|
226
|
+
analytics: r.analytics,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// RujiraRange
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
/**
|
|
233
|
+
* Builders + queries for RUJI Trade CCL range positions.
|
|
234
|
+
*
|
|
235
|
+
* ExecuteMsg shapes (keep in sync with `rujira-ui` Range.tsx / RangeManage.tsx):
|
|
236
|
+
*
|
|
237
|
+
* - create: `{ range: { create: { config: { high, low, spread, skew, fee } } } }`
|
|
238
|
+
* - deposit: `{ range: { deposit: { idx } } }`
|
|
239
|
+
* - withdraw: `{ range: { withdraw: { idx, amount } } }` (amount is the share Decimal 4dp)
|
|
240
|
+
* - claim: `{ range: { claim: { idx } } }`
|
|
241
|
+
* - transfer: `{ range: { transfer: { idx, to } } }`
|
|
242
|
+
*/
|
|
243
|
+
export class RujiraRange {
|
|
244
|
+
constructor(client) {
|
|
245
|
+
this.client = client;
|
|
246
|
+
}
|
|
247
|
+
// ------------------- Builders -------------------
|
|
248
|
+
buildCreatePosition(params) {
|
|
249
|
+
assertPairAddress(params.pairAddress);
|
|
250
|
+
assertConfig(params.config);
|
|
251
|
+
assertCoin('base', params.base);
|
|
252
|
+
assertCoin('quote', params.quote);
|
|
253
|
+
return {
|
|
254
|
+
contractAddress: params.pairAddress,
|
|
255
|
+
executeMsg: {
|
|
256
|
+
range: {
|
|
257
|
+
create: {
|
|
258
|
+
config: {
|
|
259
|
+
high: params.config.high,
|
|
260
|
+
low: params.config.low,
|
|
261
|
+
spread: params.config.spread,
|
|
262
|
+
skew: params.config.skew,
|
|
263
|
+
fee: params.config.fee,
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
funds: [params.base, params.quote].sort((a, b) => (a.denom < b.denom ? -1 : 1)),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
buildDeposit(params) {
|
|
272
|
+
assertPairAddress(params.pairAddress);
|
|
273
|
+
assertIdx(params.idx);
|
|
274
|
+
assertCoin('base', params.base);
|
|
275
|
+
assertCoin('quote', params.quote);
|
|
276
|
+
return {
|
|
277
|
+
contractAddress: params.pairAddress,
|
|
278
|
+
executeMsg: { range: { deposit: { idx: params.idx } } },
|
|
279
|
+
funds: [params.base, params.quote].sort((a, b) => (a.denom < b.denom ? -1 : 1)),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
buildWithdraw(params) {
|
|
283
|
+
assertPairAddress(params.pairAddress);
|
|
284
|
+
assertIdx(params.idx);
|
|
285
|
+
assertShare(params.share);
|
|
286
|
+
return {
|
|
287
|
+
contractAddress: params.pairAddress,
|
|
288
|
+
executeMsg: { range: { withdraw: { idx: params.idx, amount: params.share } } },
|
|
289
|
+
funds: [],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
buildClaim(params) {
|
|
293
|
+
assertPairAddress(params.pairAddress);
|
|
294
|
+
assertIdx(params.idx);
|
|
295
|
+
return {
|
|
296
|
+
contractAddress: params.pairAddress,
|
|
297
|
+
executeMsg: { range: { claim: { idx: params.idx } } },
|
|
298
|
+
funds: [],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
buildTransfer(params) {
|
|
302
|
+
assertPairAddress(params.pairAddress);
|
|
303
|
+
assertIdx(params.idx);
|
|
304
|
+
validateThorAddress(params.to);
|
|
305
|
+
return {
|
|
306
|
+
contractAddress: params.pairAddress,
|
|
307
|
+
executeMsg: { range: { transfer: { idx: params.idx, to: params.to } } },
|
|
308
|
+
funds: [],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Atomic close: claim fees + withdraw 100%. Emits two MsgExecuteContract
|
|
313
|
+
* payloads that MUST be signed + broadcast in a single cosmos tx. The order
|
|
314
|
+
* matters — claim first, then withdraw — to guarantee fees are harvested.
|
|
315
|
+
*/
|
|
316
|
+
buildWithdrawAll(params) {
|
|
317
|
+
assertPairAddress(params.pairAddress);
|
|
318
|
+
assertIdx(params.idx);
|
|
319
|
+
return {
|
|
320
|
+
msgs: [
|
|
321
|
+
{
|
|
322
|
+
contractAddress: params.pairAddress,
|
|
323
|
+
executeMsg: { range: { claim: { idx: params.idx } } },
|
|
324
|
+
funds: [],
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
contractAddress: params.pairAddress,
|
|
328
|
+
executeMsg: { range: { withdraw: { idx: params.idx, amount: '1' } } },
|
|
329
|
+
funds: [],
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
// ------------------- Queries -------------------
|
|
335
|
+
/**
|
|
336
|
+
* List open range positions for a THORChain address.
|
|
337
|
+
* Returns [] when the account has no positions.
|
|
338
|
+
*/
|
|
339
|
+
async getPositions(owner) {
|
|
340
|
+
validateThorAddress(owner);
|
|
341
|
+
try {
|
|
342
|
+
const nodeId = base64Encode(`Account:${owner}`);
|
|
343
|
+
const data = await gqlFetch(POSITIONS_QUERY, { id: nodeId });
|
|
344
|
+
const edges = data?.node?.fin?.ranges?.edges ?? [];
|
|
345
|
+
// Filter out partial/malformed rows before mapping — mapRange throws on missing pair.address.
|
|
346
|
+
return edges.filter(e => !!e.node?.pair?.address).map(e => mapRange(e.node));
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
throw wrapError(error);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Fetch a single range position by (pairAddress, idx).
|
|
354
|
+
* Returns null when the position doesn't exist.
|
|
355
|
+
*/
|
|
356
|
+
async getPosition(pairAddress, idx) {
|
|
357
|
+
assertPairAddress(pairAddress);
|
|
358
|
+
assertIdx(idx);
|
|
359
|
+
try {
|
|
360
|
+
const nodeId = base64Encode(`FinRange:${pairAddress}:${idx}`);
|
|
361
|
+
const data = await gqlFetch(POSITION_QUERY, { id: nodeId });
|
|
362
|
+
if (!data?.node || !data.node.pair?.address)
|
|
363
|
+
return null;
|
|
364
|
+
return mapRange(data.node);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
throw wrapError(error);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Resolve a FIN pair contract address from base + quote asset identifiers
|
|
372
|
+
* (denom or THORChain ticker). Returns null when the pair doesn't exist.
|
|
373
|
+
*/
|
|
374
|
+
async getPairAddress(base, quote) {
|
|
375
|
+
if (!base || !quote) {
|
|
376
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, 'base and quote are required');
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
const data = await gqlFetch(PAIR_QUERY, {});
|
|
380
|
+
const edges = data?.finV3?.pairs?.edges ?? [];
|
|
381
|
+
// Accept tickers, bank denoms, FIN-pair denoms ("thor.rune"), and
|
|
382
|
+
// LLM-mangled forms ("xruji" from "x/ruji", "thorrune" from "thor.rune")
|
|
383
|
+
// by normalising separators out and matching with suffix tolerance
|
|
384
|
+
// (normalised "thorrune" ends with normalised "rune" → match).
|
|
385
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
386
|
+
// Three-tier match: exact > prefix > suffix. Exact takes precedence
|
|
387
|
+
// so ambiguous inputs like "ETH" don't silently match a WETH pair
|
|
388
|
+
// just because `weth.endsWith("eth")`. We also reject multi-hit
|
|
389
|
+
// fuzzy ambiguity — if two different pairs would pass the fuzzy
|
|
390
|
+
// tier, the caller must disambiguate.
|
|
391
|
+
const matchTier = (input, candidate) => {
|
|
392
|
+
if (!input || !candidate)
|
|
393
|
+
return null;
|
|
394
|
+
const a = norm(input);
|
|
395
|
+
const b = norm(candidate);
|
|
396
|
+
if (a === b)
|
|
397
|
+
return 'exact';
|
|
398
|
+
if (a.endsWith(b) || b.endsWith(a))
|
|
399
|
+
return 'fuzzy';
|
|
400
|
+
return null;
|
|
401
|
+
};
|
|
402
|
+
const scoreNode = (n) => {
|
|
403
|
+
const bs = n.assetBase.metadata?.symbol ?? '';
|
|
404
|
+
const qs = n.assetQuote.metadata?.symbol ?? '';
|
|
405
|
+
const bd = n.assetBase.variants?.native?.denom ?? '';
|
|
406
|
+
const qd = n.assetQuote.variants?.native?.denom ?? '';
|
|
407
|
+
const baseTier = (() => {
|
|
408
|
+
const s = matchTier(base, bs);
|
|
409
|
+
const d = matchTier(base, bd);
|
|
410
|
+
if (s === 'exact' || d === 'exact')
|
|
411
|
+
return 'exact';
|
|
412
|
+
if (s === 'fuzzy' || d === 'fuzzy')
|
|
413
|
+
return 'fuzzy';
|
|
414
|
+
return null;
|
|
415
|
+
})();
|
|
416
|
+
const quoteTier = (() => {
|
|
417
|
+
const s = matchTier(quote, qs);
|
|
418
|
+
const d = matchTier(quote, qd);
|
|
419
|
+
if (s === 'exact' || d === 'exact')
|
|
420
|
+
return 'exact';
|
|
421
|
+
if (s === 'fuzzy' || d === 'fuzzy')
|
|
422
|
+
return 'fuzzy';
|
|
423
|
+
return null;
|
|
424
|
+
})();
|
|
425
|
+
if (!baseTier || !quoteTier)
|
|
426
|
+
return null;
|
|
427
|
+
// Pair is as weak as its weakest side.
|
|
428
|
+
return baseTier === 'exact' && quoteTier === 'exact' ? 'exact' : 'fuzzy';
|
|
429
|
+
};
|
|
430
|
+
const scored = edges.map(e => e.node).map(n => ({ node: n, tier: scoreNode(n) }));
|
|
431
|
+
const exact = scored.filter(s => s.tier === 'exact').map(s => s.node);
|
|
432
|
+
const fuzzy = scored.filter(s => s.tier === 'fuzzy').map(s => s.node);
|
|
433
|
+
let match;
|
|
434
|
+
if (exact.length === 1) {
|
|
435
|
+
match = exact[0];
|
|
436
|
+
}
|
|
437
|
+
else if (exact.length > 1) {
|
|
438
|
+
// Multiple exact-match pairs (shouldn't happen on a sane pair
|
|
439
|
+
// list, but reject rather than pick-first to avoid routing to
|
|
440
|
+
// the wrong pair).
|
|
441
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `ambiguous pair: ${base}/${quote} matches ${exact.length} pairs exactly`);
|
|
442
|
+
}
|
|
443
|
+
else if (fuzzy.length === 1) {
|
|
444
|
+
match = fuzzy[0];
|
|
445
|
+
}
|
|
446
|
+
else if (fuzzy.length > 1) {
|
|
447
|
+
throw new RujiraError(RujiraErrorCode.INVALID_PARAMS, `ambiguous pair: ${base}/${quote} matches ${fuzzy.length} pairs fuzzily — use exact denoms or tickers`);
|
|
448
|
+
}
|
|
449
|
+
if (!match)
|
|
450
|
+
return null;
|
|
451
|
+
return {
|
|
452
|
+
address: match.address,
|
|
453
|
+
base: {
|
|
454
|
+
symbol: match.assetBase.metadata?.symbol ?? base,
|
|
455
|
+
denom: match.assetBase.variants?.native?.denom ?? '',
|
|
456
|
+
},
|
|
457
|
+
quote: {
|
|
458
|
+
symbol: match.assetQuote.metadata?.symbol ?? quote,
|
|
459
|
+
denom: match.assetQuote.variants?.native?.denom ?? '',
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
throw wrapError(error);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vultisig/rujira",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Rujira DEX integration for Vultisig SDK - Modular TypeScript SDK for swaps and limit orders on THORChain",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,6 +30,14 @@
|
|
|
30
30
|
"import": "./dist/modules/perps.js",
|
|
31
31
|
"types": "./dist/modules/perps.d.ts"
|
|
32
32
|
},
|
|
33
|
+
"./range": {
|
|
34
|
+
"import": "./dist/modules/range.js",
|
|
35
|
+
"types": "./dist/modules/range.d.ts"
|
|
36
|
+
},
|
|
37
|
+
"./ccl": {
|
|
38
|
+
"import": "./dist/ccl/index.js",
|
|
39
|
+
"types": "./dist/ccl/index.d.ts"
|
|
40
|
+
},
|
|
33
41
|
"./signer": {
|
|
34
42
|
"import": "./dist/signer/vultisig-provider.js",
|
|
35
43
|
"types": "./dist/signer/vultisig-provider.d.ts"
|
|
@@ -67,12 +75,12 @@
|
|
|
67
75
|
"devDependencies": {
|
|
68
76
|
"@types/big.js": "^6.2.2",
|
|
69
77
|
"@types/node": "^25.5.0",
|
|
70
|
-
"@vultisig/sdk": "0.17.
|
|
78
|
+
"@vultisig/sdk": "0.17.1",
|
|
71
79
|
"typescript": "^5.9.3",
|
|
72
80
|
"vitest": "^3.0.9"
|
|
73
81
|
},
|
|
74
82
|
"peerDependencies": {
|
|
75
|
-
"@vultisig/sdk": ">=0.17.
|
|
83
|
+
"@vultisig/sdk": ">=0.17.1"
|
|
76
84
|
},
|
|
77
85
|
"peerDependenciesMeta": {
|
|
78
86
|
"@vultisig/sdk": {
|