@vultisig/rujira 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.
Files changed (118) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +254 -0
  3. package/dist/assets/amount.d.ts +80 -0
  4. package/dist/assets/amount.d.ts.map +1 -0
  5. package/dist/assets/amount.js +186 -0
  6. package/dist/assets/asset.d.ts +43 -0
  7. package/dist/assets/asset.d.ts.map +1 -0
  8. package/dist/assets/asset.js +1 -0
  9. package/dist/assets/formats.d.ts +54 -0
  10. package/dist/assets/formats.d.ts.map +1 -0
  11. package/dist/assets/formats.js +164 -0
  12. package/dist/assets/index.d.ts +27 -0
  13. package/dist/assets/index.d.ts.map +1 -0
  14. package/dist/assets/index.js +45 -0
  15. package/dist/assets/registry.d.ts +37 -0
  16. package/dist/assets/registry.d.ts.map +1 -0
  17. package/dist/assets/registry.js +487 -0
  18. package/dist/assets/router.d.ts +44 -0
  19. package/dist/assets/router.d.ts.map +1 -0
  20. package/dist/assets/router.js +142 -0
  21. package/dist/client.d.ts +70 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +250 -0
  24. package/dist/config/constants.d.ts +25 -0
  25. package/dist/config/constants.d.ts.map +1 -0
  26. package/dist/config/constants.js +36 -0
  27. package/dist/config.d.ts +41 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +72 -0
  30. package/dist/discovery/discovery.d.ts +39 -0
  31. package/dist/discovery/discovery.d.ts.map +1 -0
  32. package/dist/discovery/discovery.js +250 -0
  33. package/dist/discovery/graphql-client.d.ts +46 -0
  34. package/dist/discovery/graphql-client.d.ts.map +1 -0
  35. package/dist/discovery/graphql-client.js +137 -0
  36. package/dist/discovery/index.d.ts +9 -0
  37. package/dist/discovery/index.d.ts.map +1 -0
  38. package/dist/discovery/index.js +7 -0
  39. package/dist/discovery/types.d.ts +62 -0
  40. package/dist/discovery/types.d.ts.map +1 -0
  41. package/dist/discovery/types.js +5 -0
  42. package/dist/easy-routes.d.ts +216 -0
  43. package/dist/easy-routes.d.ts.map +1 -0
  44. package/dist/easy-routes.js +241 -0
  45. package/dist/errors.d.ts +65 -0
  46. package/dist/errors.d.ts.map +1 -0
  47. package/dist/errors.js +184 -0
  48. package/dist/index.d.ts +46 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +46 -0
  51. package/dist/modules/assets.d.ts +68 -0
  52. package/dist/modules/assets.d.ts.map +1 -0
  53. package/dist/modules/assets.js +127 -0
  54. package/dist/modules/deposit.d.ts +152 -0
  55. package/dist/modules/deposit.d.ts.map +1 -0
  56. package/dist/modules/deposit.js +233 -0
  57. package/dist/modules/index.d.ts +12 -0
  58. package/dist/modules/index.d.ts.map +1 -0
  59. package/dist/modules/index.js +9 -0
  60. package/dist/modules/orderbook.d.ts +80 -0
  61. package/dist/modules/orderbook.d.ts.map +1 -0
  62. package/dist/modules/orderbook.js +320 -0
  63. package/dist/modules/swap.d.ts +48 -0
  64. package/dist/modules/swap.d.ts.map +1 -0
  65. package/dist/modules/swap.js +318 -0
  66. package/dist/modules/withdraw.d.ts +46 -0
  67. package/dist/modules/withdraw.d.ts.map +1 -0
  68. package/dist/modules/withdraw.js +218 -0
  69. package/dist/services/fee-estimator.d.ts +14 -0
  70. package/dist/services/fee-estimator.d.ts.map +1 -0
  71. package/dist/services/fee-estimator.js +89 -0
  72. package/dist/services/price-impact.d.ts +11 -0
  73. package/dist/services/price-impact.d.ts.map +1 -0
  74. package/dist/services/price-impact.js +58 -0
  75. package/dist/signer/index.d.ts +3 -0
  76. package/dist/signer/index.d.ts.map +1 -0
  77. package/dist/signer/index.js +1 -0
  78. package/dist/signer/keysign-builder.d.ts +21 -0
  79. package/dist/signer/keysign-builder.d.ts.map +1 -0
  80. package/dist/signer/keysign-builder.js +106 -0
  81. package/dist/signer/types.d.ts +81 -0
  82. package/dist/signer/types.d.ts.map +1 -0
  83. package/dist/signer/types.js +8 -0
  84. package/dist/signer/vultisig-provider.d.ts +33 -0
  85. package/dist/signer/vultisig-provider.d.ts.map +1 -0
  86. package/dist/signer/vultisig-provider.js +242 -0
  87. package/dist/types.d.ts +375 -0
  88. package/dist/types.d.ts.map +1 -0
  89. package/dist/types.js +18 -0
  90. package/dist/utils/cache.d.ts +87 -0
  91. package/dist/utils/cache.d.ts.map +1 -0
  92. package/dist/utils/cache.js +124 -0
  93. package/dist/utils/denom-conversion.d.ts +47 -0
  94. package/dist/utils/denom-conversion.d.ts.map +1 -0
  95. package/dist/utils/denom-conversion.js +105 -0
  96. package/dist/utils/encoding.d.ts +17 -0
  97. package/dist/utils/encoding.d.ts.map +1 -0
  98. package/dist/utils/encoding.js +55 -0
  99. package/dist/utils/format.d.ts +108 -0
  100. package/dist/utils/format.d.ts.map +1 -0
  101. package/dist/utils/format.js +213 -0
  102. package/dist/utils/index.d.ts +10 -0
  103. package/dist/utils/index.d.ts.map +1 -0
  104. package/dist/utils/index.js +9 -0
  105. package/dist/utils/memo.d.ts +107 -0
  106. package/dist/utils/memo.d.ts.map +1 -0
  107. package/dist/utils/memo.js +190 -0
  108. package/dist/utils/rate-limiter.d.ts +38 -0
  109. package/dist/utils/rate-limiter.d.ts.map +1 -0
  110. package/dist/utils/rate-limiter.js +67 -0
  111. package/dist/utils/type-guards.d.ts +22 -0
  112. package/dist/utils/type-guards.d.ts.map +1 -0
  113. package/dist/utils/type-guards.js +27 -0
  114. package/dist/validation/address-validator.d.ts +15 -0
  115. package/dist/validation/address-validator.d.ts.map +1 -0
  116. package/dist/validation/address-validator.js +75 -0
  117. package/package.json +98 -0
  118. package/src/__tests__/live/README.md +47 -0
@@ -0,0 +1,48 @@
1
+ import { Coin } from '@cosmjs/proto-signing';
2
+ import type { RujiraClient } from '../client.js';
3
+ import { type EasyRouteName, type EasySwapRequest } from '../easy-routes.js';
4
+ import type { FinExecuteMsg, QuoteParams, SwapOptions, SwapQuote, SwapResult } from '../types.js';
5
+ import { type QuoteCacheOptions } from '../utils/cache.js';
6
+ export type RujiraSwapOptions = {
7
+ cache?: QuoteCacheOptions | false;
8
+ quoteExpiryBufferMs?: number;
9
+ quoteTtlMs?: number;
10
+ batchConcurrency?: number;
11
+ /** Minimum swap amount in base units. Amounts at or below this are rejected. Default: 0 (contract enforces). */
12
+ dustThreshold?: string;
13
+ };
14
+ export declare class RujiraSwap {
15
+ private readonly client;
16
+ private readonly quoteCache;
17
+ private readonly quoteExpiryBufferMs;
18
+ private readonly quoteTtlMs;
19
+ private readonly batchConcurrency;
20
+ private readonly dustThreshold;
21
+ constructor(client: RujiraClient, options?: RujiraSwapOptions);
22
+ clearCache(): void;
23
+ getCacheStats(): {
24
+ size: number;
25
+ maxSize: number;
26
+ ttlMs: number;
27
+ } | null;
28
+ getQuote(params: QuoteParams, options?: {
29
+ skipCache?: boolean;
30
+ maxStalenessMs?: number;
31
+ } | boolean): Promise<SwapQuote>;
32
+ execute(quote: SwapQuote, options?: SwapOptions): Promise<SwapResult>;
33
+ executeSwap(params: QuoteParams, options?: SwapOptions): Promise<SwapResult>;
34
+ buildTransaction(params: QuoteParams): Promise<{
35
+ contractAddress: string;
36
+ msg: FinExecuteMsg;
37
+ funds: Coin[];
38
+ }>;
39
+ buildL1Memo(params: QuoteParams): Promise<string>;
40
+ easySwap(request: EasySwapRequest): Promise<SwapResult>;
41
+ batchGetQuotes(routes: EasyRouteName[], amount: string, destination?: string): Promise<Map<EasyRouteName, SwapQuote | null>>;
42
+ getAllRouteQuotes(amount: string, destination?: string): Promise<Map<EasyRouteName, SwapQuote | null>>;
43
+ private recomputeMinimumOutput;
44
+ private findContract;
45
+ private validateQuoteParams;
46
+ private validateBalance;
47
+ }
48
+ //# sourceMappingURL=swap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"swap.d.ts","sourceRoot":"","sources":["../../src/modules/swap.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAA;AAI5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAChD,OAAO,EAAe,KAAK,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAGzF,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACjG,OAAO,EAAc,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAKtE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAAA;IACjC,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,gHAAgH;IAChH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAED,qBAAa,UAAU;IAQnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAPzB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA8B;IACzD,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAQ;IAC5C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAQ;IACnC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAQ;IACzC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAQ;gBAGnB,MAAM,EAAE,YAAY,EACrC,OAAO,GAAE,iBAAsB;IASjC,UAAU,IAAI,IAAI;IAIlB,aAAa,IAAI;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAIlE,QAAQ,CACZ,MAAM,EAAE,WAAW,EACnB,OAAO,GAAE;QAAE,SAAS,CAAC,EAAE,OAAO,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAe,GAC1E,OAAO,CAAC,SAAS,CAAC;IAiGf,OAAO,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;IAoDzE,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;IAKhF,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC;QACnD,eAAe,EAAE,MAAM,CAAA;QACvB,GAAG,EAAE,aAAa,CAAA;QAClB,KAAK,EAAE,IAAI,EAAE,CAAA;KACd,CAAC;IAoCI,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC;IAMjD,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,UAAU,CAAC;IA2CvD,cAAc,CAClB,MAAM,EAAE,aAAa,EAAE,EACvB,MAAM,EAAE,MAAM,EACd,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,GAAG,CAAC,aAAa,EAAE,SAAS,GAAG,IAAI,CAAC,CAAC;IAqC1C,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,EAAE,SAAS,GAAG,IAAI,CAAC,CAAC;IAK5G,OAAO,CAAC,sBAAsB;YAOhB,YAAY;IAiC1B,OAAO,CAAC,mBAAmB;YAoCb,eAAe;CA6B9B"}
@@ -0,0 +1,318 @@
1
+ import Big from 'big.js';
2
+ import { Amount, findAssetByFormat } from '../assets/index.js';
3
+ import { EASY_ROUTES } from '../easy-routes.js';
4
+ import { RujiraError, RujiraErrorCode } from '../errors.js';
5
+ import { calculatePriceImpact } from '../services/price-impact.js';
6
+ import { QuoteCache } from '../utils/cache.js';
7
+ import { base64Encode } from '../utils/encoding.js';
8
+ import { calculateMinReturn, generateQuoteId } from '../utils/format.js';
9
+ import { validateThorAddress } from '../validation/address-validator.js';
10
+ export class RujiraSwap {
11
+ constructor(client, options = {}) {
12
+ this.client = client;
13
+ this.quoteCache = options.cache === false ? null : new QuoteCache(options.cache);
14
+ this.quoteExpiryBufferMs = options.quoteExpiryBufferMs ?? 60000;
15
+ this.quoteTtlMs = options.quoteTtlMs ?? 120000;
16
+ this.batchConcurrency = options.batchConcurrency ?? 3;
17
+ this.dustThreshold = BigInt(options.dustThreshold ?? '0');
18
+ }
19
+ clearCache() {
20
+ this.quoteCache?.clear();
21
+ }
22
+ getCacheStats() {
23
+ return this.quoteCache?.stats() ?? null;
24
+ }
25
+ async getQuote(params, options = false) {
26
+ const skipCache = typeof options === 'boolean' ? options : (options.skipCache ?? false);
27
+ const maxStalenessMs = typeof options === 'boolean' ? undefined : options.maxStalenessMs;
28
+ this.validateQuoteParams(params);
29
+ if (params.destination) {
30
+ validateThorAddress(params.destination);
31
+ }
32
+ if (!skipCache && this.quoteCache) {
33
+ const cached = this.quoteCache.get(params.fromAsset, params.toAsset, params.amount);
34
+ if (cached) {
35
+ if (Date.now() >= cached.expiresAt) {
36
+ // expired; fall through to fetch
37
+ }
38
+ else if (maxStalenessMs !== undefined && cached.cachedAt) {
39
+ const age = Date.now() - cached.cachedAt;
40
+ if (age <= maxStalenessMs) {
41
+ return this.recomputeMinimumOutput(cached, params.slippageBps);
42
+ }
43
+ }
44
+ else {
45
+ const age = cached.cachedAt ? Date.now() - cached.cachedAt : 0;
46
+ if (age > 5000) {
47
+ return this.recomputeMinimumOutput({
48
+ ...cached,
49
+ warning: cached.warning ??
50
+ `Quote is ${Math.round(age / 1000)}s old. Consider refreshing for volatile markets.`,
51
+ }, params.slippageBps);
52
+ }
53
+ return this.recomputeMinimumOutput(cached, params.slippageBps);
54
+ }
55
+ }
56
+ }
57
+ const fromAsset = findAssetByFormat(params.fromAsset);
58
+ const toAsset = findAssetByFormat(params.toAsset);
59
+ if (!fromAsset) {
60
+ throw new RujiraError(RujiraErrorCode.INVALID_ASSET, `Unknown asset: ${params.fromAsset}`);
61
+ }
62
+ if (!toAsset) {
63
+ throw new RujiraError(RujiraErrorCode.INVALID_ASSET, `Unknown asset: ${params.toAsset}`);
64
+ }
65
+ const contractAddress = await this.findContract(params.fromAsset, params.toAsset);
66
+ const [simulation, orderbook] = await Promise.all([
67
+ this.client.simulateSwap(contractAddress, fromAsset.formats.fin, params.amount),
68
+ this.client.orderbook.getOrderBook(contractAddress).catch(() => null),
69
+ ]);
70
+ const slippageBps = params.slippageBps ?? this.client.config.defaultSlippageBps;
71
+ const minimumOutput = calculateMinReturn(simulation.returned, slippageBps);
72
+ const inputAmount = Big(params.amount);
73
+ const outputAmount = Big(simulation.returned);
74
+ const rate = outputAmount.gt(0)
75
+ ? inputAmount.mul(100000000).div(outputAmount).toFixed(0, 0) // round down
76
+ : '0';
77
+ const priceImpact = calculatePriceImpact(params.amount, simulation.returned, orderbook);
78
+ const priceImpactEstimated = !orderbook || !orderbook.bids[0]?.price || !orderbook.asks[0]?.price;
79
+ const quote = {
80
+ params,
81
+ expectedOutput: simulation.returned,
82
+ minimumOutput,
83
+ rate,
84
+ priceImpact,
85
+ fees: {
86
+ network: '0',
87
+ protocol: simulation.fee,
88
+ affiliate: '0',
89
+ total: simulation.fee,
90
+ },
91
+ contractAddress,
92
+ expiresAt: Date.now() + this.quoteTtlMs,
93
+ quoteId: generateQuoteId(),
94
+ cachedAt: Date.now(),
95
+ warning: priceImpactEstimated || priceImpact === 'unknown'
96
+ ? 'Price impact is estimated or unknown - orderbook data unavailable. Actual slippage may differ.'
97
+ : undefined,
98
+ };
99
+ this.quoteCache?.set(params.fromAsset, params.toAsset, params.amount, quote);
100
+ return quote;
101
+ }
102
+ async execute(quote, options = {}) {
103
+ const effectiveExpiry = quote.expiresAt - this.quoteExpiryBufferMs;
104
+ if (Date.now() > effectiveExpiry) {
105
+ const isActuallyExpired = Date.now() > quote.expiresAt;
106
+ throw new RujiraError(RujiraErrorCode.QUOTE_EXPIRED, isActuallyExpired
107
+ ? 'Quote has expired. Please get a new quote.'
108
+ : `Quote is about to expire (within ${this.quoteExpiryBufferMs}ms safety buffer). Please get a new quote to ensure execution completes.`);
109
+ }
110
+ if (!options.skipBalanceValidation) {
111
+ await this.validateBalance(quote.params.fromAsset, quote.params.amount);
112
+ }
113
+ const fromAsset = findAssetByFormat(quote.params.fromAsset);
114
+ if (!fromAsset) {
115
+ throw new RujiraError(RujiraErrorCode.INVALID_ASSET, `Unknown asset: ${quote.params.fromAsset}`);
116
+ }
117
+ const slippageBps = options.slippageBps ?? quote.params.slippageBps ?? this.client.config.defaultSlippageBps;
118
+ const minReturn = calculateMinReturn(quote.expectedOutput, slippageBps);
119
+ const swapMsg = {
120
+ swap: {
121
+ min: {
122
+ min_return: minReturn,
123
+ to: quote.params.destination,
124
+ },
125
+ },
126
+ };
127
+ const funds = [
128
+ {
129
+ denom: fromAsset.formats.fin,
130
+ amount: quote.params.amount,
131
+ },
132
+ ];
133
+ const result = await this.client.executeContract(quote.contractAddress, swapMsg, funds, options.memo);
134
+ return {
135
+ txHash: result.transactionHash,
136
+ status: 'pending',
137
+ fromAmount: quote.params.amount,
138
+ fee: quote.fees.total,
139
+ timestamp: Date.now(),
140
+ };
141
+ }
142
+ async executeSwap(params, options = {}) {
143
+ const quote = await this.getQuote(params);
144
+ return this.execute(quote, options);
145
+ }
146
+ async buildTransaction(params) {
147
+ const quote = await this.getQuote(params);
148
+ const fromAsset = findAssetByFormat(params.fromAsset);
149
+ if (!fromAsset) {
150
+ throw new RujiraError(RujiraErrorCode.INVALID_ASSET, `Unknown asset: ${params.fromAsset}`);
151
+ }
152
+ const minReturn = calculateMinReturn(quote.expectedOutput, params.slippageBps ?? this.client.config.defaultSlippageBps);
153
+ const msg = {
154
+ swap: {
155
+ min: {
156
+ min_return: minReturn,
157
+ to: params.destination,
158
+ },
159
+ },
160
+ };
161
+ const funds = [
162
+ {
163
+ denom: fromAsset.formats.fin,
164
+ amount: params.amount,
165
+ },
166
+ ];
167
+ return {
168
+ contractAddress: quote.contractAddress,
169
+ msg,
170
+ funds,
171
+ };
172
+ }
173
+ async buildL1Memo(params) {
174
+ const { contractAddress, msg } = await this.buildTransaction(params);
175
+ const msgBase64 = base64Encode(JSON.stringify(msg));
176
+ return `x:${contractAddress}:${msgBase64}`;
177
+ }
178
+ async easySwap(request) {
179
+ validateThorAddress(request.destination);
180
+ let fromAsset;
181
+ let toAsset;
182
+ if (request.route) {
183
+ const route = EASY_ROUTES[request.route];
184
+ if (!route) {
185
+ throw new RujiraError(RujiraErrorCode.INVALID_PAIR, `Unknown easy route: ${request.route}. Use listEasyRoutes() to see available routes.`);
186
+ }
187
+ fromAsset = route.from;
188
+ toAsset = route.to;
189
+ }
190
+ else if (request.from && request.to) {
191
+ fromAsset = request.from;
192
+ toAsset = request.to;
193
+ }
194
+ else {
195
+ throw new RujiraError(RujiraErrorCode.INVALID_PAIR, 'EasySwapRequest must specify either route or both from and to');
196
+ }
197
+ await this.validateBalance(fromAsset, request.amount);
198
+ const slippageBps = request.maxSlippagePercent !== undefined ? Math.round(request.maxSlippagePercent * 100) : undefined;
199
+ const quoteParams = {
200
+ fromAsset,
201
+ toAsset,
202
+ amount: request.amount,
203
+ destination: request.destination,
204
+ slippageBps,
205
+ };
206
+ const quote = await this.getQuote(quoteParams);
207
+ return this.execute(quote, { skipBalanceValidation: true });
208
+ }
209
+ async batchGetQuotes(routes, amount, destination) {
210
+ const results = [];
211
+ for (let i = 0; i < routes.length; i += this.batchConcurrency) {
212
+ const batch = routes.slice(i, i + this.batchConcurrency);
213
+ const batchResults = await Promise.all(batch.map(async (routeName) => {
214
+ try {
215
+ const route = EASY_ROUTES[routeName];
216
+ if (!route) {
217
+ return { routeName, quote: null };
218
+ }
219
+ const quote = await this.getQuote({
220
+ fromAsset: route.from,
221
+ toAsset: route.to,
222
+ amount,
223
+ destination,
224
+ });
225
+ return { routeName, quote };
226
+ }
227
+ catch {
228
+ return { routeName, quote: null };
229
+ }
230
+ }));
231
+ results.push(...batchResults);
232
+ }
233
+ const resultMap = new Map();
234
+ for (const { routeName, quote } of results) {
235
+ resultMap.set(routeName, quote);
236
+ }
237
+ return resultMap;
238
+ }
239
+ async getAllRouteQuotes(amount, destination) {
240
+ const allRoutes = Object.keys(EASY_ROUTES);
241
+ return this.batchGetQuotes(allRoutes, amount, destination);
242
+ }
243
+ recomputeMinimumOutput(quote, slippageBps) {
244
+ const effectiveSlippage = slippageBps ?? quote.params.slippageBps ?? this.client.config.defaultSlippageBps;
245
+ const minimumOutput = calculateMinReturn(quote.expectedOutput, effectiveSlippage);
246
+ if (minimumOutput === quote.minimumOutput)
247
+ return quote;
248
+ return { ...quote, minimumOutput, params: { ...quote.params, slippageBps: effectiveSlippage } };
249
+ }
250
+ async findContract(fromAsset, toAsset) {
251
+ const pairKey = `${fromAsset}/${toAsset}`;
252
+ const reversePairKey = `${toAsset}/${fromAsset}`;
253
+ const knownContracts = this.client.config.contracts.finContracts;
254
+ if (knownContracts[pairKey]) {
255
+ return knownContracts[pairKey];
256
+ }
257
+ if (knownContracts[reversePairKey]) {
258
+ return knownContracts[reversePairKey];
259
+ }
260
+ let address = await this.client.discovery.getContractAddress(fromAsset, toAsset);
261
+ if (!address) {
262
+ address = await this.client.discovery.getContractAddress(toAsset, fromAsset);
263
+ }
264
+ if (address) {
265
+ this.client.config.contracts.finContracts[pairKey] = address;
266
+ this.client.persistFinContracts().catch(() => undefined);
267
+ return address;
268
+ }
269
+ throw new RujiraError(RujiraErrorCode.INVALID_PAIR, `No FIN contract found for pair: ${fromAsset}/${toAsset}. ` +
270
+ 'Market may not exist on Rujira or discovery failed.');
271
+ }
272
+ validateQuoteParams(params) {
273
+ if (!params.fromAsset) {
274
+ throw new RujiraError(RujiraErrorCode.INVALID_ASSET, 'fromAsset is required');
275
+ }
276
+ if (!params.toAsset) {
277
+ throw new RujiraError(RujiraErrorCode.INVALID_ASSET, 'toAsset is required');
278
+ }
279
+ if (params.fromAsset === params.toAsset) {
280
+ throw new RujiraError(RujiraErrorCode.INVALID_PAIR, 'Cannot swap asset to itself');
281
+ }
282
+ if (!params.amount || !/^\d+$/.test(params.amount)) {
283
+ throw new RujiraError(RujiraErrorCode.INVALID_AMOUNT, 'amount must be a positive integer string in base units');
284
+ }
285
+ if (BigInt(params.amount) <= 0n) {
286
+ throw new RujiraError(RujiraErrorCode.INVALID_AMOUNT, 'amount must be greater than zero');
287
+ }
288
+ if (this.dustThreshold > 0n && BigInt(params.amount) <= this.dustThreshold) {
289
+ throw new RujiraError(RujiraErrorCode.INVALID_AMOUNT, `Swap amount ${params.amount} is at or below dust threshold (${this.dustThreshold}). ` +
290
+ `Minimum swap amount: ${(this.dustThreshold + 1n).toString()}`);
291
+ }
292
+ if (params.slippageBps !== undefined) {
293
+ if (params.slippageBps < 1 || params.slippageBps > 5000) {
294
+ throw new RujiraError(RujiraErrorCode.INVALID_SLIPPAGE, 'slippageBps must be between 1 (0.01%) and 5000 (50%)');
295
+ }
296
+ }
297
+ }
298
+ async validateBalance(fromAsset, amount) {
299
+ const asset = findAssetByFormat(fromAsset);
300
+ if (!asset) {
301
+ throw new RujiraError(RujiraErrorCode.INVALID_ASSET, `Unknown asset: ${fromAsset}`);
302
+ }
303
+ const ticker = asset.id.toUpperCase();
304
+ const address = await this.client.getAddress();
305
+ const balance = await this.client.getBalance(address, asset.formats.fin);
306
+ const required = Amount.fromRaw(BigInt(amount), asset, 'fin');
307
+ const available = Amount.fromRaw(BigInt(balance.amount || '0'), asset, 'fin');
308
+ if (available.raw < required.raw) {
309
+ throw new RujiraError(RujiraErrorCode.INSUFFICIENT_BALANCE, `Insufficient ${ticker} balance. Required: ${required.toHuman()}, Available: ${available.toHuman()}`, {
310
+ asset: fromAsset,
311
+ denom: asset.formats.fin,
312
+ required: amount,
313
+ available: balance.amount,
314
+ shortfall: (required.raw - available.raw).toString(),
315
+ });
316
+ }
317
+ }
318
+ }
@@ -0,0 +1,46 @@
1
+ import type { Coin } from '@cosmjs/proto-signing';
2
+ import type { RujiraClient } from '../client.js';
3
+ export type WithdrawParams = {
4
+ asset: string;
5
+ amount: string;
6
+ l1Address: string;
7
+ maxFeeBps?: number;
8
+ };
9
+ export type PreparedWithdraw = {
10
+ chain: string;
11
+ asset: string;
12
+ denom: string;
13
+ amount: string;
14
+ destination: string;
15
+ memo: string;
16
+ estimatedFee: string;
17
+ estimatedTimeMinutes: number;
18
+ funds: Coin[];
19
+ };
20
+ export type WithdrawResult = {
21
+ txHash: string;
22
+ asset: string;
23
+ amount: string;
24
+ destination: string;
25
+ status: 'pending' | 'success' | 'failed';
26
+ };
27
+ export declare class RujiraWithdraw {
28
+ private readonly client;
29
+ private thornodeUrl;
30
+ constructor(client: RujiraClient);
31
+ prepare(params: WithdrawParams): Promise<PreparedWithdraw>;
32
+ execute(prepared: PreparedWithdraw): Promise<WithdrawResult>;
33
+ private getAccountInfo;
34
+ private getNetworkFee;
35
+ buildWithdrawMemo(l1Address: string): string;
36
+ estimateWithdrawTime(chain: string): number;
37
+ estimateWithdrawFee(asset: string, amount: string): Promise<string>;
38
+ getMinimumWithdraw(asset: string): Promise<string>;
39
+ canWithdraw(asset: string): Promise<{
40
+ possible: boolean;
41
+ reason?: string;
42
+ }>;
43
+ private validateWithdrawParams;
44
+ private parseAsset;
45
+ }
46
+ //# sourceMappingURL=withdraw.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"withdraw.d.ts","sourceRoot":"","sources":["../../src/modules/withdraw.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAA;AAGjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAchD,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,YAAY,EAAE,MAAM,CAAA;IACpB,oBAAoB,EAAE,MAAM,CAAA;IAC5B,KAAK,EAAE,IAAI,EAAE,CAAA;CACd,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAA;CACzC,CAAA;AAwBD,qBAAa,cAAc;IAGb,OAAO,CAAC,QAAQ,CAAC,MAAM;IAFnC,OAAO,CAAC,WAAW,CAAQ;gBAEE,MAAM,EAAE,YAAY;IAI3C,OAAO,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAyC1D,OAAO,CAAC,QAAQ,EAAE,gBAAgB,GAAG,OAAO,CAAC,cAAc,CAAC;YA0EpD,cAAc;YA2Bd,aAAa;IAiB3B,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM;IAI5C,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAIrC,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAInE,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA8BlD,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAmCjF,OAAO,CAAC,sBAAsB;IAsB9B,OAAO,CAAC,UAAU;CAInB"}
@@ -0,0 +1,218 @@
1
+ import { findAssetByFormat } from '../assets/index.js';
2
+ import { CHAIN_PROCESSING_TIMES } from '../config.js';
3
+ import { DEFAULT_THORCHAIN_FEE } from '../config/constants.js';
4
+ import { RujiraError, RujiraErrorCode } from '../errors.js';
5
+ import { estimateWithdrawFee } from '../services/fee-estimator.js';
6
+ import { buildWithdrawalKeysignPayload } from '../signer/keysign-builder.js';
7
+ import { isWithdrawCapable } from '../signer/types.js';
8
+ import { parseAsset as sharedParseAsset } from '../utils/denom-conversion.js';
9
+ import { buildSecureRedeemMemo } from '../utils/memo.js';
10
+ import { thornodeRateLimiter } from '../utils/rate-limiter.js';
11
+ import { isFinAsset, parseAsset } from '../utils/type-guards.js';
12
+ import { validateL1Address } from '../validation/address-validator.js';
13
+ function hasVaultAccess(signer) {
14
+ return (signer !== null &&
15
+ typeof signer === 'object' &&
16
+ 'getVault' in signer &&
17
+ typeof signer.getVault === 'function');
18
+ }
19
+ export class RujiraWithdraw {
20
+ constructor(client) {
21
+ this.client = client;
22
+ this.thornodeUrl = client.config.restEndpoint;
23
+ }
24
+ async prepare(params) {
25
+ this.validateWithdrawParams(params);
26
+ const { chain } = parseAsset(params.asset);
27
+ const assetData = findAssetByFormat(params.asset);
28
+ if (!isFinAsset(assetData)) {
29
+ throw new RujiraError(RujiraErrorCode.INVALID_ASSET, `Unknown asset: ${params.asset}`);
30
+ }
31
+ const denom = assetData.formats.fin;
32
+ validateL1Address(chain, params.l1Address);
33
+ const memo = this.buildWithdrawMemo(params.l1Address);
34
+ const fee = await this.estimateWithdrawFee(params.asset, params.amount);
35
+ if (BigInt(params.amount) <= BigInt(fee)) {
36
+ throw new RujiraError(RujiraErrorCode.INVALID_AMOUNT, `Withdrawal amount (${params.amount}) is too small to cover estimated outbound fee (${fee}) for ${params.asset}. ` +
37
+ 'Try a larger amount or wait for lower gas.');
38
+ }
39
+ const funds = [{ denom, amount: params.amount }];
40
+ return {
41
+ chain,
42
+ asset: params.asset,
43
+ denom,
44
+ amount: params.amount,
45
+ destination: params.l1Address,
46
+ memo,
47
+ estimatedFee: fee,
48
+ estimatedTimeMinutes: this.estimateWithdrawTime(chain),
49
+ funds,
50
+ };
51
+ }
52
+ async execute(prepared) {
53
+ if (!this.client.canSign()) {
54
+ throw new RujiraError(RujiraErrorCode.MISSING_SIGNER, 'Cannot execute withdrawal without a signer');
55
+ }
56
+ const clientInternal = this.client;
57
+ const signer = clientInternal.signer;
58
+ if (!hasVaultAccess(signer)) {
59
+ throw new RujiraError(RujiraErrorCode.MISSING_SIGNER, 'Withdrawal requires a VultisigRujiraProvider signer with vault access. ' +
60
+ 'Standard Cosmos signers are not supported for MsgDeposit operations.');
61
+ }
62
+ try {
63
+ const vault = signer.getVault();
64
+ if (!isWithdrawCapable(vault)) {
65
+ throw new RujiraError(RujiraErrorCode.SIGNING_FAILED, 'Vault does not support withdrawal operations. ' +
66
+ 'Required methods: extractMessageHashes, sign, broadcastTx.');
67
+ }
68
+ const senderAddress = await vault.address('THORChain');
69
+ const [accountInfo, fee] = await Promise.all([this.getAccountInfo(senderAddress), this.getNetworkFee()]);
70
+ const keysignPayload = await buildWithdrawalKeysignPayload({
71
+ vault,
72
+ senderAddress,
73
+ prepared,
74
+ accountInfo,
75
+ fee,
76
+ });
77
+ const messageHashes = await vault.extractMessageHashes(keysignPayload);
78
+ const signature = await vault.sign({
79
+ transaction: keysignPayload,
80
+ chain: 'THORChain',
81
+ messageHashes,
82
+ });
83
+ const txHash = await vault.broadcastTx({
84
+ chain: 'THORChain',
85
+ keysignPayload,
86
+ signature,
87
+ });
88
+ return {
89
+ txHash,
90
+ asset: prepared.asset,
91
+ amount: prepared.amount,
92
+ destination: prepared.destination,
93
+ status: 'pending',
94
+ };
95
+ }
96
+ catch (error) {
97
+ if (error instanceof RujiraError) {
98
+ throw error;
99
+ }
100
+ const errorMsg = error instanceof Error ? error.message : String(error);
101
+ throw new RujiraError(RujiraErrorCode.CONTRACT_ERROR, `Withdrawal execution failed: ${errorMsg}. To withdraw manually, use the Vultisig mobile app with memo: ${prepared.memo}`, { originalError: errorMsg, prepared });
102
+ }
103
+ }
104
+ async getAccountInfo(address) {
105
+ try {
106
+ const response = await thornodeRateLimiter.fetch(`${this.thornodeUrl}/auth/accounts/${address}`);
107
+ if (!response.ok) {
108
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
109
+ }
110
+ const data = (await response.json());
111
+ const accountData = data.result?.value || data.account;
112
+ if (!accountData) {
113
+ throw new Error('Invalid account response structure');
114
+ }
115
+ return {
116
+ accountNumber: accountData.account_number || '0',
117
+ sequence: accountData.sequence || '0',
118
+ };
119
+ }
120
+ catch (error) {
121
+ throw new RujiraError(RujiraErrorCode.NETWORK_ERROR, `Failed to fetch account info for ${address}: ${error instanceof Error ? error.message : String(error)}`);
122
+ }
123
+ }
124
+ async getNetworkFee() {
125
+ try {
126
+ const response = await thornodeRateLimiter.fetch(`${this.thornodeUrl}/thorchain/network`);
127
+ if (response.ok) {
128
+ const data = (await response.json());
129
+ if (data.native_tx_fee_rune) {
130
+ return BigInt(data.native_tx_fee_rune);
131
+ }
132
+ }
133
+ }
134
+ catch {
135
+ // fall back below
136
+ }
137
+ return DEFAULT_THORCHAIN_FEE;
138
+ }
139
+ buildWithdrawMemo(l1Address) {
140
+ return buildSecureRedeemMemo(l1Address);
141
+ }
142
+ estimateWithdrawTime(chain) {
143
+ return CHAIN_PROCESSING_TIMES[chain.toUpperCase()] || 15;
144
+ }
145
+ async estimateWithdrawFee(asset, amount) {
146
+ return estimateWithdrawFee(this.thornodeUrl, asset, amount);
147
+ }
148
+ async getMinimumWithdraw(asset) {
149
+ const { chain } = parseAsset(asset);
150
+ try {
151
+ const response = await thornodeRateLimiter.fetch(`${this.thornodeUrl}/thorchain/inbound_addresses`);
152
+ if (response.ok) {
153
+ const addresses = (await response.json());
154
+ const chainInfo = addresses.find(a => a.chain === chain);
155
+ if (chainInfo) {
156
+ return chainInfo.dust_threshold;
157
+ }
158
+ }
159
+ }
160
+ catch {
161
+ // fall back below
162
+ }
163
+ const defaults = {
164
+ BTC: '10000',
165
+ ETH: '0',
166
+ BSC: '0',
167
+ AVAX: '0',
168
+ GAIA: '0',
169
+ DOGE: '100000000',
170
+ LTC: '10000',
171
+ BCH: '10000',
172
+ };
173
+ return defaults[chain] || '0';
174
+ }
175
+ async canWithdraw(asset) {
176
+ const { chain } = parseAsset(asset);
177
+ try {
178
+ const response = await thornodeRateLimiter.fetch(`${this.thornodeUrl}/thorchain/inbound_addresses`);
179
+ if (!response.ok) {
180
+ return { possible: false, reason: 'Cannot reach THORNode' };
181
+ }
182
+ const addresses = (await response.json());
183
+ const chainInfo = addresses.find(a => a.chain === chain);
184
+ if (!chainInfo) {
185
+ return { possible: false, reason: `Chain ${chain} not supported` };
186
+ }
187
+ if (chainInfo.halted) {
188
+ return { possible: false, reason: `Chain ${chain} is halted` };
189
+ }
190
+ if (chainInfo.chain_trading_paused || chainInfo.global_trading_paused) {
191
+ return { possible: true, reason: 'Trading paused - withdrawals may be delayed' };
192
+ }
193
+ return { possible: true };
194
+ }
195
+ catch {
196
+ return { possible: false, reason: 'Network error checking withdrawal status' };
197
+ }
198
+ }
199
+ validateWithdrawParams(params) {
200
+ if (!params.asset || !params.asset.includes('.')) {
201
+ throw new RujiraError(RujiraErrorCode.INVALID_ASSET, `Invalid asset format: ${params.asset}. Expected format: CHAIN.SYMBOL`);
202
+ }
203
+ if (!params.amount || !/^\d+$/.test(params.amount)) {
204
+ throw new RujiraError(RujiraErrorCode.INVALID_AMOUNT, 'Amount must be a positive integer in base units');
205
+ }
206
+ const amountBigInt = BigInt(params.amount);
207
+ if (amountBigInt <= 0n) {
208
+ throw new RujiraError(RujiraErrorCode.INVALID_AMOUNT, 'Amount must be greater than zero');
209
+ }
210
+ if (!params.l1Address || params.l1Address.length === 0) {
211
+ throw new RujiraError(RujiraErrorCode.INVALID_ADDRESS, 'L1 destination address is required');
212
+ }
213
+ }
214
+ parseAsset(asset) {
215
+ const parsed = sharedParseAsset(asset);
216
+ return { chain: parsed.chain, symbol: parsed.symbol };
217
+ }
218
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Withdraw fee estimation service
3
+ * @module services/fee-estimator
4
+ */
5
+ /**
6
+ * Estimate the outbound fee for a withdrawal.
7
+ *
8
+ * Resolution order:
9
+ * 1. Fetch live outbound_fee from THORNode inbound_addresses
10
+ * 2. Fall back to hardcoded FALLBACK_OUTBOUND_FEES
11
+ * 3. For non-gas-asset tokens, convert via pool ratio
12
+ */
13
+ export declare function estimateWithdrawFee(thornodeUrl: string, asset: string, _amount: string): Promise<string>;
14
+ //# sourceMappingURL=fee-estimator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fee-estimator.d.ts","sourceRoot":"","sources":["../../src/services/fee-estimator.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAqBH;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACvC,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC,CA2EjB"}