@xoxno/sdk-js 1.0.224 → 1.0.226
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 +39 -0
- package/dist/cjs/sdk/stellar/index.d.ts +1 -0
- package/dist/cjs/sdk/stellar/read/__tests__/client.test.d.ts +1 -0
- package/dist/cjs/sdk/stellar/read/client.d.ts +19 -0
- package/dist/cjs/sdk/stellar/read/index.d.ts +4 -0
- package/dist/cjs/sdk/stellar/read/schemas.d.ts +370 -0
- package/dist/cjs/sdk/stellar/read/types.d.ts +83 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/sdk/index.js +198 -0
- package/dist/esm/sdk/stellar/admin.js +385 -0
- package/dist/esm/sdk/stellar/auth-policy.js +310 -0
- package/dist/esm/sdk/stellar/cctp.js +23 -0
- package/dist/esm/sdk/stellar/contracts.js +49 -0
- package/dist/esm/sdk/stellar/events/decode.js +415 -0
- package/dist/esm/sdk/stellar/events/id.js +62 -0
- package/dist/esm/sdk/stellar/events/index.js +2 -0
- package/dist/esm/sdk/stellar/governance.js +403 -0
- package/dist/esm/sdk/stellar/index.js +26 -0
- package/dist/esm/sdk/stellar/lending-api-types.js +3 -0
- package/dist/esm/sdk/stellar/lending-read-types.js +1 -0
- package/dist/esm/sdk/stellar/lending-read.js +341 -0
- package/dist/esm/sdk/stellar/lending.js +358 -0
- package/dist/esm/sdk/stellar/math/account.js +256 -0
- package/dist/esm/sdk/stellar/math/index.js +11 -0
- package/dist/esm/sdk/stellar/math/limits.js +273 -0
- package/dist/esm/sdk/stellar/math/projection.js +130 -0
- package/dist/esm/sdk/stellar/math/rates.js +62 -0
- package/dist/esm/sdk/stellar/math/scaled.js +156 -0
- package/dist/esm/sdk/stellar/position-mode.js +11 -0
- package/dist/esm/sdk/stellar/prepare.js +47 -0
- package/dist/esm/sdk/stellar/quote.js +87 -0
- package/dist/esm/sdk/stellar/read/client.js +82 -0
- package/dist/esm/sdk/stellar/read/index.js +3 -0
- package/dist/esm/sdk/stellar/read/schemas.js +158 -0
- package/dist/esm/sdk/stellar/read/types.js +1 -0
- package/dist/esm/sdk/stellar/repay-swap.js +12 -0
- package/dist/esm/sdk/stellar/route-verify.js +285 -0
- package/dist/esm/sdk/stellar/scval-encode.js +464 -0
- package/dist/esm/sdk/stellar/swap-history.js +46 -0
- package/dist/esm/sdk/stellar/swap-index.js +1 -0
- package/dist/esm/sdk/stellar/swap.js +117 -0
- package/dist/esm/sdk/stellar/xdr-compat.js +17 -0
- package/dist/esm/sdk/stellar-schema.js +129 -0
- package/dist/esm/sdk/swagger.js +1957 -0
- package/dist/esm/sdk/types.js +1 -0
- package/dist/esm/sdk/utils.js +21 -0
- package/dist/esm/utils/api.js +169 -0
- package/dist/esm/utils/const.js +11 -0
- package/dist/esm/utils/errors.js +20 -0
- package/dist/esm/utils/guards.js +29 -0
- package/dist/esm/utils/helpers.js +20 -0
- package/dist/esm/utils/regex.js +14 -0
- package/dist/esm/utils/soroban-errors.js +149 -0
- package/dist/index.cjs +1 -1
- package/dist/index.esm.js +1 -2
- package/dist/sdk/stellar/index.cjs +1 -1
- package/dist/sdk/stellar/index.d.ts +1 -0
- package/dist/sdk/stellar/index.esm.js +1 -1
- package/dist/sdk/stellar/read/__tests__/client.test.d.ts +1 -0
- package/dist/sdk/stellar/read/client.d.ts +19 -0
- package/dist/sdk/stellar/read/index.cjs +1 -0
- package/dist/sdk/stellar/read/index.d.ts +4 -0
- package/dist/sdk/stellar/read/index.esm.js +1 -0
- package/dist/sdk/stellar/read/schemas.d.ts +370 -0
- package/dist/sdk/stellar/read/types.d.ts +83 -0
- package/package.json +20 -4
- package/dist/index.esm.js.LICENSE.txt +0 -8
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { TransactionBuilder, rpc } from "@stellar/stellar-sdk";
|
|
2
|
+
import { STELLAR_NETWORK_PASSPHRASE } from "./contracts.js";
|
|
3
|
+
export function tagStellarInvokedContractError(contractId, error) {
|
|
4
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5
|
+
return new Error(`[xoxno-invoked:${contractId}] ${message}`);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Simulate a built Soroban envelope (footprint, auth, resource fee) and return
|
|
9
|
+
* prepared base64 XDR. Optionally tag failures with the invoked contract id so
|
|
10
|
+
* UIs can map `Error(Contract, #N)` codes to the right ABI.
|
|
11
|
+
* @param server - Host application's RPC server on the selected network.
|
|
12
|
+
* @param xdr - Unsigned builder output, not a signed transaction.
|
|
13
|
+
* @param opts - Pass `network` explicitly; it must match the RPC and wallet.
|
|
14
|
+
* @returns Prepared, unsigned base64 XDR for wallet signing.
|
|
15
|
+
* @remarks This helper does not sign, submit, or wait for confirmation. Existing
|
|
16
|
+
* calls without `network` retain a neutral hash domain because XDR contains no
|
|
17
|
+
* passphrase; always select the actual network when the wallet signs.
|
|
18
|
+
* @category Transaction preparation
|
|
19
|
+
*/ export async function prepareStellarTxXdr(server, xdr, opts) {
|
|
20
|
+
const instructionLeeway = opts?.instructionLeeway ?? 20000000;
|
|
21
|
+
if (!Number.isInteger(instructionLeeway) || instructionLeeway < 0 || instructionLeeway > 0xffffffff) {
|
|
22
|
+
throw new Error('instructionLeeway must be an integer from 0 to 4294967295');
|
|
23
|
+
}
|
|
24
|
+
const tx = TransactionBuilder.fromXDR(xdr, opts?.network ? STELLAR_NETWORK_PASSPHRASE[opts.network] : '');
|
|
25
|
+
try {
|
|
26
|
+
const simulation = await server.simulateTransaction(tx, {
|
|
27
|
+
cpuInstructions: instructionLeeway
|
|
28
|
+
});
|
|
29
|
+
if (rpc.Api.isSimulationError(simulation)) throw new Error(simulation.error);
|
|
30
|
+
return rpc.assembleTransaction(tx, simulation).build().toXDR();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (opts?.invokedContractId) {
|
|
33
|
+
throw tagStellarInvokedContractError(opts.invokedContractId, error);
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Prepare {@link BuiltStellarTx} before wallet signing.
|
|
40
|
+
* @param server - Host RPC server for the selected deployment.
|
|
41
|
+
* @param built - Unsigned output of any Stellar transaction builder.
|
|
42
|
+
* @param opts - Network and optional contract context for error mapping.
|
|
43
|
+
* @returns Prepared unsigned base64 XDR; sign this result, then submit it.
|
|
44
|
+
* @category Transaction preparation
|
|
45
|
+
*/ export async function prepareStellarBuiltTx(server, built, opts) {
|
|
46
|
+
return prepareStellarTxXdr(server, built.xdr, opts);
|
|
47
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed client for the Stellar aggregator quote server
|
|
3
|
+
* (`GET /api/v1/quote`, `GET /api/v1/tokens`).
|
|
4
|
+
*
|
|
5
|
+
* The quote server is a standalone Rust service (deployed independently
|
|
6
|
+
* of the main XOXNO API), so this module hits its base URL directly
|
|
7
|
+
* rather than routing through the swagger-driven endpoints map.
|
|
8
|
+
*
|
|
9
|
+
* All response shapes are mirrored 1:1 by `@xoxno/types` DTOs
|
|
10
|
+
* (`StellarAggregatorQuoteResponseDto`, `StellarQuotePathDto`, etc.) —
|
|
11
|
+
* the wire shape is camelCase, the Soroban contract receives snake_case
|
|
12
|
+
* after the SDK encoder rewrites field names.
|
|
13
|
+
*/ const buildUrl = (base, path, query)=>{
|
|
14
|
+
const url = new URL(path, base.endsWith('/') ? base : `${base}/`);
|
|
15
|
+
if (query) {
|
|
16
|
+
for (const [key, value] of Object.entries(query)){
|
|
17
|
+
if (value === undefined) continue;
|
|
18
|
+
url.searchParams.set(key, String(value));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return url.toString();
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Fetch a route quote from the Stellar aggregator quote server.
|
|
25
|
+
*
|
|
26
|
+
* Forward mode: pass `amountIn` to compute the maximum reachable
|
|
27
|
+
* `amountOut`. Reverse mode: pass `amountOut` to compute the minimum
|
|
28
|
+
* `amountIn` that delivers at least that output.
|
|
29
|
+
*
|
|
30
|
+
* For lending strategies, pass the quote's `routeXdr` to the builder's
|
|
31
|
+
* `steps`. Amounts are token base-unit decimal strings. Requote after changes
|
|
32
|
+
* to amount, direction or slippage; preparation is the execution check.
|
|
33
|
+
*
|
|
34
|
+
* Passing `sender` (G...) together with `slippage` returns a direct-swap
|
|
35
|
+
* envelope under `transaction.envelopeXdr`. The router comes from the service's
|
|
36
|
+
* `/api/v1/config`; the `router` request field is ignored by the service and is
|
|
37
|
+
* no longer sent. Prepare the envelope before wallet signing unless
|
|
38
|
+
* `transaction.simulated` is true.
|
|
39
|
+
* @param request - Exactly one of amountIn or amountOut; optional referralId is forwarded unchanged.
|
|
40
|
+
* @param opts - Quote-server URL and optional fetch options supplied by the host.
|
|
41
|
+
* @returns Quote estimates and opaque route bytes; does not execute a swap.
|
|
42
|
+
* @category Strategy quotes
|
|
43
|
+
*/ export async function getStellarAggregatorQuote(request, opts) {
|
|
44
|
+
if (!opts.baseUrl) {
|
|
45
|
+
throw new Error('Stellar quote baseUrl is required');
|
|
46
|
+
}
|
|
47
|
+
if (request.amountIn == null === (request.amountOut == null)) {
|
|
48
|
+
throw new Error('getStellarAggregatorQuote: exactly one of `amountIn` or `amountOut` must be provided');
|
|
49
|
+
}
|
|
50
|
+
const url = buildUrl(opts.baseUrl, 'api/v1/quote', {
|
|
51
|
+
from: request.from,
|
|
52
|
+
to: request.to,
|
|
53
|
+
amountIn: request.amountIn,
|
|
54
|
+
amountOut: request.amountOut,
|
|
55
|
+
maxHops: request.maxHops,
|
|
56
|
+
maxSplits: request.maxSplits,
|
|
57
|
+
slippage: request.slippage,
|
|
58
|
+
includePaths: request.includePaths,
|
|
59
|
+
sender: request.sender,
|
|
60
|
+
simulate: request.simulate,
|
|
61
|
+
referralId: request.referralId,
|
|
62
|
+
platform: request.platform,
|
|
63
|
+
fresh: request.fresh
|
|
64
|
+
});
|
|
65
|
+
const res = await fetch(url, opts.fetchOptions);
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const body = await res.text().catch(()=>'');
|
|
68
|
+
throw new Error(`Stellar quote server responded ${res.status} ${res.statusText} for ${url} — ${body}`);
|
|
69
|
+
}
|
|
70
|
+
return await res.json();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* List tokens currently indexed by the quote server. Useful to populate
|
|
74
|
+
* pickers or validate that a user-supplied token has on-chain liquidity.
|
|
75
|
+
* @category Strategy quotes
|
|
76
|
+
*/ export async function getStellarQuoteTokens(opts) {
|
|
77
|
+
if (!opts.baseUrl) {
|
|
78
|
+
throw new Error('Stellar quote baseUrl is required');
|
|
79
|
+
}
|
|
80
|
+
const url = buildUrl(opts.baseUrl, 'api/v1/tokens');
|
|
81
|
+
const res = await fetch(url, opts.fetchOptions);
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
const body = await res.text().catch(()=>'');
|
|
84
|
+
throw new Error(`Stellar quote server responded ${res.status} ${res.statusText} for ${url} — ${body}`);
|
|
85
|
+
}
|
|
86
|
+
return await res.json();
|
|
87
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
function nextLink(header) {
|
|
2
|
+
if (!header) return undefined;
|
|
3
|
+
// Match complete link-values, including commas inside URIs and quoted parameters.
|
|
4
|
+
const link = /\s*<([^<>\s]+)>\s*((?:;\s*[\w!#$%&'*+.^`|~-]+\s*=\s*(?:"(?:[^"\\\r\n]|\\.)*"|[\w!#$%&'*+.^`|~:/-]+)\s*)*)(,|$)/gy;
|
|
5
|
+
let next;
|
|
6
|
+
let offset = 0;
|
|
7
|
+
while(offset < header.length){
|
|
8
|
+
const match = link.exec(header);
|
|
9
|
+
if (!match || match[3] && link.lastIndex === header.length) {
|
|
10
|
+
throw new Error('Invalid pagination Link header');
|
|
11
|
+
}
|
|
12
|
+
let isNext = false;
|
|
13
|
+
let hasRelation = false;
|
|
14
|
+
for (const parameter of match[2].matchAll(/;\s*([\w!#$%&'*+.^`|~-]+)\s*=\s*(?:"((?:[^"\\\r\n]|\\.)*)"|([\w!#$%&'*+.^`|~:/-]+))\s*/g)){
|
|
15
|
+
if (parameter[1].toLowerCase() !== 'rel') continue;
|
|
16
|
+
if (hasRelation) throw new Error('Duplicate pagination link relation');
|
|
17
|
+
hasRelation = true;
|
|
18
|
+
const relation = (parameter[2] ?? parameter[3]).replace(/\\(.)/g, '$1');
|
|
19
|
+
isNext = relation.toLowerCase().split(/\s+/).includes('next');
|
|
20
|
+
}
|
|
21
|
+
if (isNext) {
|
|
22
|
+
if (next !== undefined) throw new Error('Multiple next pagination links');
|
|
23
|
+
next = match[1];
|
|
24
|
+
}
|
|
25
|
+
offset = link.lastIndex;
|
|
26
|
+
}
|
|
27
|
+
return next;
|
|
28
|
+
}
|
|
29
|
+
export function createStellarLendingReadClient(options) {
|
|
30
|
+
const base = new URL(options.baseUrl.trim());
|
|
31
|
+
if (![
|
|
32
|
+
'https:',
|
|
33
|
+
'http:'
|
|
34
|
+
].includes(base.protocol) || base.username || base.password || base.search || base.hash) {
|
|
35
|
+
throw new Error('baseUrl must be an HTTP API root without credentials, query or fragment');
|
|
36
|
+
}
|
|
37
|
+
base.pathname = `${base.pathname.replace(/\/+$/, '')}/`;
|
|
38
|
+
const fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
39
|
+
if (!fetcher) throw new Error('A fetch implementation is required');
|
|
40
|
+
async function pages(path, signal) {
|
|
41
|
+
const endpoint = new URL(`stellar-lending/v1/${path}`, base);
|
|
42
|
+
let url = endpoint;
|
|
43
|
+
const visited = new Set();
|
|
44
|
+
const result = [];
|
|
45
|
+
while(true){
|
|
46
|
+
if (visited.has(url.href)) throw new Error('Pagination Link cycle');
|
|
47
|
+
visited.add(url.href);
|
|
48
|
+
// Reject redirects before a custom fetch could forward credentials to another host.
|
|
49
|
+
const response = await fetcher(url.href, {
|
|
50
|
+
signal,
|
|
51
|
+
redirect: 'error'
|
|
52
|
+
});
|
|
53
|
+
if (response.redirected || response.url && new URL(response.url).href !== url.href) {
|
|
54
|
+
throw new Error('Stellar lending redirects are not allowed');
|
|
55
|
+
}
|
|
56
|
+
if (!response.ok) throw new Error(`Stellar lending request failed: HTTP ${response.status}`);
|
|
57
|
+
const rows = await response.json();
|
|
58
|
+
if (!Array.isArray(rows)) throw new Error('Stellar lending response must be an array');
|
|
59
|
+
result.push(...rows);
|
|
60
|
+
const next = nextLink(response.headers.get('Link'));
|
|
61
|
+
if (!next) break;
|
|
62
|
+
const target = new URL(next, url);
|
|
63
|
+
if (target.origin !== endpoint.origin || target.pathname !== endpoint.pathname || target.username || target.password || target.hash) {
|
|
64
|
+
throw new Error('Pagination Link must stay on the same API endpoint');
|
|
65
|
+
}
|
|
66
|
+
url = target;
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
assets: ({ usage, signal } = {})=>{
|
|
72
|
+
if (usage !== undefined && usage !== 'collateral' && usage !== 'borrow') {
|
|
73
|
+
throw new Error('usage must be collateral or borrow');
|
|
74
|
+
}
|
|
75
|
+
return pages(`assets${usage ? `?usage=${usage}` : ''}`, signal);
|
|
76
|
+
},
|
|
77
|
+
positions: (owner, { signal } = {})=>{
|
|
78
|
+
if (!owner.trim() || owner === '.' || owner === '..') throw new Error('owner is required');
|
|
79
|
+
return pages(`users/${encodeURIComponent(owner)}/positions`, signal);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Inline OpenAPI 3 schemas shared with servers; no decorators or runtime dependencies.
|
|
2
|
+
const string = {
|
|
3
|
+
type: 'string'
|
|
4
|
+
};
|
|
5
|
+
const number = {
|
|
6
|
+
type: 'number'
|
|
7
|
+
};
|
|
8
|
+
const boolean = {
|
|
9
|
+
type: 'boolean'
|
|
10
|
+
};
|
|
11
|
+
const nullableString = {
|
|
12
|
+
...string,
|
|
13
|
+
nullable: true
|
|
14
|
+
};
|
|
15
|
+
const nullableNumber = {
|
|
16
|
+
...number,
|
|
17
|
+
nullable: true
|
|
18
|
+
};
|
|
19
|
+
const network = {
|
|
20
|
+
...string,
|
|
21
|
+
enum: [
|
|
22
|
+
'mainnet',
|
|
23
|
+
'testnet'
|
|
24
|
+
]
|
|
25
|
+
};
|
|
26
|
+
const dataStatus = {
|
|
27
|
+
...string,
|
|
28
|
+
enum: [
|
|
29
|
+
'ready',
|
|
30
|
+
'stale',
|
|
31
|
+
'incomplete'
|
|
32
|
+
]
|
|
33
|
+
};
|
|
34
|
+
function object(properties) {
|
|
35
|
+
return {
|
|
36
|
+
type: 'object',
|
|
37
|
+
required: Object.keys(properties),
|
|
38
|
+
properties
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const token = {
|
|
42
|
+
sac: string,
|
|
43
|
+
symbol: string,
|
|
44
|
+
name: string,
|
|
45
|
+
decimals: nullableNumber,
|
|
46
|
+
logoUrl: nullableString
|
|
47
|
+
};
|
|
48
|
+
const priceStatus = object({
|
|
49
|
+
valid: boolean,
|
|
50
|
+
stale: boolean,
|
|
51
|
+
deviation: boolean,
|
|
52
|
+
timestamp: nullableNumber
|
|
53
|
+
});
|
|
54
|
+
const positionAsset = object({
|
|
55
|
+
...token,
|
|
56
|
+
hubId: number,
|
|
57
|
+
hubName: string,
|
|
58
|
+
amountRaw: {
|
|
59
|
+
...nullableString,
|
|
60
|
+
description: 'Integer token base units, never scaled shares.'
|
|
61
|
+
},
|
|
62
|
+
amount: nullableString,
|
|
63
|
+
apy: {
|
|
64
|
+
...nullableNumber,
|
|
65
|
+
description: 'Fraction: 0.05 means 5%.'
|
|
66
|
+
},
|
|
67
|
+
priceUsd: nullableNumber,
|
|
68
|
+
valueUsd: nullableNumber,
|
|
69
|
+
priceStatus,
|
|
70
|
+
indexRay: nullableString,
|
|
71
|
+
indexedAt: number,
|
|
72
|
+
indexedLedger: number
|
|
73
|
+
});
|
|
74
|
+
const capacity = object({
|
|
75
|
+
amountRaw: nullableString,
|
|
76
|
+
amount: nullableString,
|
|
77
|
+
usd: nullableNumber
|
|
78
|
+
});
|
|
79
|
+
const spoke = object({
|
|
80
|
+
spokeId: number,
|
|
81
|
+
spokeName: string,
|
|
82
|
+
ltvBps: number,
|
|
83
|
+
supplyCapacity: capacity,
|
|
84
|
+
borrowCapacity: capacity,
|
|
85
|
+
canSupply: boolean,
|
|
86
|
+
canBeCollateral: boolean,
|
|
87
|
+
canBorrow: boolean,
|
|
88
|
+
canOpenAccount: {
|
|
89
|
+
...boolean,
|
|
90
|
+
description: 'Spoke admits new accounts; other entry gates still apply.'
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
/** OpenAPI response schema for LendingPosition[]. Every property is required; nullable data remains explicit. */ export const lendingPositionsSchema = {
|
|
94
|
+
type: 'array',
|
|
95
|
+
items: object({
|
|
96
|
+
accountId: string,
|
|
97
|
+
nftContract: string,
|
|
98
|
+
network,
|
|
99
|
+
spokeId: nullableNumber,
|
|
100
|
+
spokeName: nullableString,
|
|
101
|
+
supplied: {
|
|
102
|
+
type: 'array',
|
|
103
|
+
items: positionAsset
|
|
104
|
+
},
|
|
105
|
+
borrow: {
|
|
106
|
+
type: 'array',
|
|
107
|
+
items: positionAsset
|
|
108
|
+
},
|
|
109
|
+
hasDebt: {
|
|
110
|
+
...boolean,
|
|
111
|
+
nullable: true,
|
|
112
|
+
description: 'Null when account rows are unavailable.'
|
|
113
|
+
},
|
|
114
|
+
healthFactor: {
|
|
115
|
+
...nullableNumber,
|
|
116
|
+
description: 'Null without debt or when risk inputs are unavailable.'
|
|
117
|
+
},
|
|
118
|
+
borrowLimitUsd: nullableNumber,
|
|
119
|
+
availableBorrowUsd: {
|
|
120
|
+
...nullableNumber,
|
|
121
|
+
description: 'Account headroom, before per-market constraints.'
|
|
122
|
+
},
|
|
123
|
+
netApy: {
|
|
124
|
+
...nullableNumber,
|
|
125
|
+
description: 'Net annual yield divided by positive net equity; fraction.'
|
|
126
|
+
},
|
|
127
|
+
dataStatus
|
|
128
|
+
})
|
|
129
|
+
};
|
|
130
|
+
/** OpenAPI response schema for LendingAsset[]. Capacities are indexed estimates. */ export const lendingAssetsSchema = {
|
|
131
|
+
type: 'array',
|
|
132
|
+
items: object({
|
|
133
|
+
...token,
|
|
134
|
+
hubId: number,
|
|
135
|
+
hubName: string,
|
|
136
|
+
network,
|
|
137
|
+
supplyApy: {
|
|
138
|
+
...nullableNumber,
|
|
139
|
+
description: 'Fraction: 0.05 means 5%.'
|
|
140
|
+
},
|
|
141
|
+
borrowApy: {
|
|
142
|
+
...nullableNumber,
|
|
143
|
+
description: 'Fraction: 0.05 means 5%.'
|
|
144
|
+
},
|
|
145
|
+
priceUsd: nullableNumber,
|
|
146
|
+
priceStatus,
|
|
147
|
+
spokes: {
|
|
148
|
+
type: 'array',
|
|
149
|
+
items: spoke
|
|
150
|
+
},
|
|
151
|
+
dataStatus,
|
|
152
|
+
indexedAt: {
|
|
153
|
+
...number,
|
|
154
|
+
description: 'Pool state timestamp. Capacities are indexed snapshot estimates; prepare/simulate before signing.'
|
|
155
|
+
},
|
|
156
|
+
indexedLedger: number
|
|
157
|
+
})
|
|
158
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/** Render-ready REST data. Null means unavailable, never an inferred zero. */ export { };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Empty strategy swap bytes for `repay_debt_with_collateral` when
|
|
3
|
+
* `collateral_token === debt_token`. The controller requires the swap payload
|
|
4
|
+
* to be genuinely empty in this case (`swap.is_empty()`) and reverts
|
|
5
|
+
* `InvalidPayments` if any route is supplied, even an unused placeholder.
|
|
6
|
+
*
|
|
7
|
+
* `token`/`collateralAmount` are accepted-but-ignored so existing callers of
|
|
8
|
+
* the old `(token, collateralAmount)` signature keep compiling; the swap
|
|
9
|
+
* payload is always empty regardless of their values.
|
|
10
|
+
*/ export function buildSameTokenRepaySwapSteps(_token, _collateralAmount) {
|
|
11
|
+
return new Uint8Array(0);
|
|
12
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
function _define_property(obj, key, value) {
|
|
2
|
+
if (key in obj) {
|
|
3
|
+
Object.defineProperty(obj, key, {
|
|
4
|
+
value: value,
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
obj[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Decoder and pre-signing verifier for the aggregator router's packed route
|
|
16
|
+
* (`routeXdr` / `swap_xdr`).
|
|
17
|
+
*
|
|
18
|
+
* The quote server is unauthenticated and returns the route as an opaque blob
|
|
19
|
+
* next to separate JSON display fields. The router enforces only the minimum
|
|
20
|
+
* that is ENCODED in the blob, so a caller must decode it and compare it with
|
|
21
|
+
* its own request before a wallet signs. The decoder mirrors
|
|
22
|
+
* `contracts/swap-aggregator/src/program.rs` (`Program::decode` + `validate`)
|
|
23
|
+
* and `execute::run` check for check; the encoder is `./scval-encode.ts`.
|
|
24
|
+
*/ import { Address, Asset, scValToBigInt, StrKey, xdr } from "@stellar/stellar-sdk";
|
|
25
|
+
import { STELLAR_NETWORK_PASSPHRASE } from "./contracts.js";
|
|
26
|
+
import { xdrBytes, xdrField, xdrType } from "./xdr-compat.js";
|
|
27
|
+
import { asStellarStrategySwapBytes, MAX_AMOUNTS, MAX_ASSETS, MAX_OPS, MAX_WEIGHTS, MODE_ALL, MODE_FIXED_BASE, MODE_PPM_BASE, MODE_PREV, OP_BURN, OP_MINT, PPM_DENOMINATOR, STELLAR_PROGRAM_VERSION, STELLAR_SWAP_VENUES } from "./scval-encode.js";
|
|
28
|
+
const HEADER_LEN = 10;
|
|
29
|
+
const OP_LEN = 5;
|
|
30
|
+
const WEIGHT_LEN = 3;
|
|
31
|
+
const MAX_PROGRAM_BYTES = HEADER_LEN + OP_LEN * MAX_OPS + WEIGHT_LEN * MAX_WEIGHTS;
|
|
32
|
+
/**
|
|
33
|
+
* Cap on the serialized payload, checked before XDR parsing so a hostile
|
|
34
|
+
* response cannot force a proportional allocation. The largest legal payload
|
|
35
|
+
* (256 addresses, 126 amounts, 346 program bytes) is below 16 KiB.
|
|
36
|
+
*/ export const STELLAR_ROUTE_MAX_BYTES = 16 * 1024;
|
|
37
|
+
/** Thrown when a route cannot be decoded or disagrees with the caller's request. */ export class StellarRouteVerificationError extends Error {
|
|
38
|
+
constructor(code, message){
|
|
39
|
+
super(`Stellar route verification failed [${code}]: ${message}`), _define_property(this, "code", void 0);
|
|
40
|
+
this.name = 'StellarRouteVerificationError';
|
|
41
|
+
this.code = code;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const fail = (code, message)=>{
|
|
45
|
+
throw new StellarRouteVerificationError(code, message);
|
|
46
|
+
};
|
|
47
|
+
const toBuffer = (route)=>{
|
|
48
|
+
const encoded = typeof route === 'string' ? route : route && typeof route === 'object' && 'routeXdr' in route ? route.routeXdr : undefined;
|
|
49
|
+
// Base64 expands 3 bytes to 4 characters; reject before decoding.
|
|
50
|
+
if (typeof encoded === 'string' && encoded.length > STELLAR_ROUTE_MAX_BYTES * 2) {
|
|
51
|
+
return fail('OVERSIZED', `encoded route has ${encoded.length} characters`);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return xdrBytes(asStellarStrategySwapBytes(route));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return fail('MALFORMED', error instanceof Error ? error.message : String(error));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const readPayloadFields = (buf)=>{
|
|
60
|
+
let value;
|
|
61
|
+
try {
|
|
62
|
+
// `fromXDR` rejects truncated input and trailing bytes.
|
|
63
|
+
value = xdr.ScVal.fromXDR(buf);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return fail('MALFORMED', `payload is not one XDR ScVal: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
if (xdrType(value) !== 'scvMap') {
|
|
68
|
+
return fail('MALFORMED', 'payload must be a StrategyPayload map');
|
|
69
|
+
}
|
|
70
|
+
const entries = xdrField(value, 'map') ?? [];
|
|
71
|
+
// A `#[contracttype]` struct is a map with exactly its fields, keys sorted.
|
|
72
|
+
const keys = entries.map((entry)=>xdrType(xdrField(entry, 'key')) === 'scvSymbol' ? xdrField(xdrField(entry, 'key'), 'sym').toString() : '');
|
|
73
|
+
if (keys.join(',') !== 'amounts,assets,ops') {
|
|
74
|
+
return fail('MALFORMED', 'payload fields must be exactly amounts, assets, ops');
|
|
75
|
+
}
|
|
76
|
+
const [amountsVal, assetsVal, opsVal] = entries.map((entry)=>xdrField(entry, 'val'));
|
|
77
|
+
if (xdrType(amountsVal) !== 'scvVec' || xdrType(assetsVal) !== 'scvVec' || xdrType(opsVal) !== 'scvBytes') {
|
|
78
|
+
return fail('MALFORMED', 'payload field has the wrong type');
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
amounts: xdrField(amountsVal, 'vec') ?? [],
|
|
82
|
+
assets: xdrField(assetsVal, 'vec') ?? [],
|
|
83
|
+
ops: xdrBytes(opsVal)
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Decode and structurally validate route bytes. Applies every check of the
|
|
88
|
+
* on-chain decoder, so a route that `Program::decode` rejects does not decode
|
|
89
|
+
* here. Venue behaviour (pools, liquidity, budget) is not checked.
|
|
90
|
+
* @throws StellarRouteVerificationError
|
|
91
|
+
*/ export function decodeStellarRouteBytes(route) {
|
|
92
|
+
const buf = toBuffer(route);
|
|
93
|
+
if (buf.length === 0) fail('MALFORMED', 'route is empty');
|
|
94
|
+
if (buf.length > STELLAR_ROUTE_MAX_BYTES) {
|
|
95
|
+
fail('OVERSIZED', `route has ${buf.length} bytes, above ${STELLAR_ROUTE_MAX_BYTES}`);
|
|
96
|
+
}
|
|
97
|
+
const fields = readPayloadFields(buf);
|
|
98
|
+
const assetsLen = fields.assets.length;
|
|
99
|
+
const amountsLen = fields.amounts.length;
|
|
100
|
+
if (assetsLen === 0) fail('MALFORMED', 'asset registry is empty');
|
|
101
|
+
if (assetsLen > MAX_ASSETS || amountsLen > MAX_AMOUNTS) {
|
|
102
|
+
fail('OVERSIZED', `registries hold ${assetsLen} assets and ${amountsLen} amounts`);
|
|
103
|
+
}
|
|
104
|
+
const assets = fields.assets.map((value, i)=>{
|
|
105
|
+
if (xdrType(value) !== 'scvAddress') {
|
|
106
|
+
return fail('MALFORMED', `assets[${i}] is not an address`);
|
|
107
|
+
}
|
|
108
|
+
return Address.fromScAddress(xdrField(value, 'address')).toString();
|
|
109
|
+
});
|
|
110
|
+
const amounts = fields.amounts.map((value, i)=>{
|
|
111
|
+
if (xdrType(value) !== 'scvI128') {
|
|
112
|
+
return fail('MALFORMED', `amounts[${i}] is not an i128`);
|
|
113
|
+
}
|
|
114
|
+
return scValToBigInt(value);
|
|
115
|
+
});
|
|
116
|
+
const ops = fields.ops;
|
|
117
|
+
if (ops.length < HEADER_LEN) fail('MALFORMED', 'program header is truncated');
|
|
118
|
+
if (ops.length > MAX_PROGRAM_BYTES) {
|
|
119
|
+
fail('OVERSIZED', `program has ${ops.length} bytes, above ${MAX_PROGRAM_BYTES}`);
|
|
120
|
+
}
|
|
121
|
+
const byte = (at)=>{
|
|
122
|
+
const value = ops[at];
|
|
123
|
+
return value === undefined ? fail('MALFORMED', `program byte ${at} is missing`) : value;
|
|
124
|
+
};
|
|
125
|
+
const version = byte(0);
|
|
126
|
+
if (version !== STELLAR_PROGRAM_VERSION) {
|
|
127
|
+
fail('UNSUPPORTED_VERSION', `program version ${version}, expected ${STELLAR_PROGRAM_VERSION}`);
|
|
128
|
+
}
|
|
129
|
+
const opCount = byte(8);
|
|
130
|
+
const weightCount = byte(9);
|
|
131
|
+
if (opCount === 0) fail('INVALID_STRUCTURE', 'program has no instructions');
|
|
132
|
+
if (opCount > MAX_OPS || weightCount > MAX_WEIGHTS) {
|
|
133
|
+
fail('OVERSIZED', `program declares ${opCount} instructions and ${weightCount} weights`);
|
|
134
|
+
}
|
|
135
|
+
const weightsAt = HEADER_LEN + OP_LEN * opCount;
|
|
136
|
+
if (ops.length !== weightsAt + WEIGHT_LEN * weightCount) {
|
|
137
|
+
fail('MALFORMED', 'program length does not match its instruction and weight counts');
|
|
138
|
+
}
|
|
139
|
+
const tokenInIdx = byte(1);
|
|
140
|
+
const tokenOutIdx = byte(2);
|
|
141
|
+
const minOutIdx = byte(3);
|
|
142
|
+
if (tokenInIdx >= assetsLen || tokenOutIdx >= assetsLen || minOutIdx >= amountsLen) {
|
|
143
|
+
fail('MALFORMED', 'header index is outside its registry');
|
|
144
|
+
}
|
|
145
|
+
if (tokenInIdx === tokenOutIdx) fail('INVALID_STRUCTURE', 'token_in equals token_out');
|
|
146
|
+
const referralId = ops.readUInt32BE(4);
|
|
147
|
+
const weights = [];
|
|
148
|
+
for(let i = 0; i < weightCount; i += 1){
|
|
149
|
+
const ppm = ops.readUIntBE(weightsAt + WEIGHT_LEN * i, WEIGHT_LEN);
|
|
150
|
+
if (ppm === 0 || ppm > PPM_DENOMINATOR) {
|
|
151
|
+
fail('INVALID_STRUCTURE', `split weight ${ppm} outside 1..=${PPM_DENOMINATOR}`);
|
|
152
|
+
}
|
|
153
|
+
weights.push(ppm);
|
|
154
|
+
}
|
|
155
|
+
const asset = (idx)=>assets[idx] ?? fail('MALFORMED', `asset index ${idx} is outside the registry`);
|
|
156
|
+
const amount = (idx)=>amounts[idx] ?? fail('MALFORMED', `amount index ${idx} is outside the registry`);
|
|
157
|
+
const decoded = [];
|
|
158
|
+
// Registry index of the previous instruction's single output; a burn has none.
|
|
159
|
+
let producedIdx;
|
|
160
|
+
for(let i = 0; i < opCount; i += 1){
|
|
161
|
+
const at = HEADER_LEN + OP_LEN * i;
|
|
162
|
+
const opcode = byte(at);
|
|
163
|
+
const modeByte = byte(at + 1);
|
|
164
|
+
const idxA = byte(at + 2);
|
|
165
|
+
const idxB = byte(at + 3);
|
|
166
|
+
const idxC = byte(at + 4);
|
|
167
|
+
if (opcode > OP_MINT) fail('UNKNOWN_OPCODE', `instruction ${i} has opcode ${opcode}`);
|
|
168
|
+
let mode;
|
|
169
|
+
if (modeByte === MODE_ALL) {
|
|
170
|
+
mode = {
|
|
171
|
+
kind: 'all'
|
|
172
|
+
};
|
|
173
|
+
} else if (modeByte === MODE_PREV) {
|
|
174
|
+
// `Prev` needs a predecessor with one output, and that output must be
|
|
175
|
+
// this input. The contract compares registry INDICES, so do the same.
|
|
176
|
+
if (producedIdx === undefined || producedIdx !== idxB) {
|
|
177
|
+
fail('INVALID_STRUCTURE', `instruction ${i} does not chain onto its predecessor`);
|
|
178
|
+
}
|
|
179
|
+
mode = {
|
|
180
|
+
kind: 'prev'
|
|
181
|
+
};
|
|
182
|
+
} else if (modeByte >= MODE_PPM_BASE) {
|
|
183
|
+
const ppm = weights[modeByte - MODE_PPM_BASE];
|
|
184
|
+
if (ppm === undefined) fail('MALFORMED', `instruction ${i} weight index is out of range`);
|
|
185
|
+
mode = {
|
|
186
|
+
kind: 'ppm',
|
|
187
|
+
ppm: ppm
|
|
188
|
+
};
|
|
189
|
+
} else {
|
|
190
|
+
mode = {
|
|
191
|
+
kind: 'fixed',
|
|
192
|
+
amount: amount(modeByte - MODE_FIXED_BASE)
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const pool = asset(idxA);
|
|
196
|
+
if (opcode === OP_BURN || opcode === OP_MINT) {
|
|
197
|
+
if (mode.kind !== 'all') fail('MALFORMED', `liquidity instruction ${i} must use mode All`);
|
|
198
|
+
if (idxC >= amountsLen) fail('MALFORMED', `instruction ${i} amount index is out of range`);
|
|
199
|
+
decoded.push({
|
|
200
|
+
kind: opcode === OP_BURN ? 'burn' : 'mint',
|
|
201
|
+
pool,
|
|
202
|
+
shareToken: asset(idxB),
|
|
203
|
+
amountIndex: idxC
|
|
204
|
+
});
|
|
205
|
+
producedIdx = opcode === OP_MINT ? idxB : undefined;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (idxB === idxC) fail('INVALID_STRUCTURE', `instruction ${i} swaps a token for itself`);
|
|
209
|
+
decoded.push({
|
|
210
|
+
kind: 'swap',
|
|
211
|
+
venue: STELLAR_SWAP_VENUES[opcode],
|
|
212
|
+
mode,
|
|
213
|
+
pool,
|
|
214
|
+
tokenIn: asset(idxB),
|
|
215
|
+
tokenOut: asset(idxC)
|
|
216
|
+
});
|
|
217
|
+
producedIdx = idxC;
|
|
218
|
+
}
|
|
219
|
+
const totalMinOut = amount(minOutIdx);
|
|
220
|
+
// `execute::run` rejects a non-positive minimum.
|
|
221
|
+
if (totalMinOut <= 0n) fail('MIN_OUT_TOO_LOW', `encoded total_min_out is ${totalMinOut}`);
|
|
222
|
+
return {
|
|
223
|
+
version,
|
|
224
|
+
tokenIn: asset(tokenInIdx),
|
|
225
|
+
tokenOut: asset(tokenOutIdx),
|
|
226
|
+
totalMinOut,
|
|
227
|
+
referralId,
|
|
228
|
+
assets,
|
|
229
|
+
amounts,
|
|
230
|
+
ops: decoded,
|
|
231
|
+
weights
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Contract id of a quote-server token id: `C…` as is, `XLM` / `native` and
|
|
236
|
+
* `CODE:GISSUER` through the Stellar Asset Contract id of `network`.
|
|
237
|
+
* @throws StellarRouteVerificationError when the id needs a network and none is given.
|
|
238
|
+
*/ export function stellarTokenContractId(tokenId, network) {
|
|
239
|
+
if (StrKey.isValidContract(tokenId)) return tokenId;
|
|
240
|
+
if (!network) {
|
|
241
|
+
return fail('EXPECTATION_MISSING', `token ${tokenId} is not a contract id; pass \`network\` to derive its asset contract`);
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const sep = tokenId.indexOf(':');
|
|
245
|
+
const classic = tokenId === 'XLM' || tokenId === 'native' ? Asset.native() : new Asset(tokenId.slice(0, sep), tokenId.slice(sep + 1));
|
|
246
|
+
return classic.contractId(STELLAR_NETWORK_PASSPHRASE[network]);
|
|
247
|
+
} catch {
|
|
248
|
+
return fail('MALFORMED', `token id ${tokenId} is not a contract id or a classic asset`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const toAmount = (value, label)=>{
|
|
252
|
+
if (typeof value === 'bigint') return value;
|
|
253
|
+
if (!/^\d+$/.test(value)) {
|
|
254
|
+
return fail('EXPECTATION_MISSING', `${label} must be a base-unit integer string`);
|
|
255
|
+
}
|
|
256
|
+
return BigInt(value);
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Decode `route` and require it to match `expected`. Call before signing any
|
|
260
|
+
* transaction that carries route bytes; never sign when this throws.
|
|
261
|
+
* @returns The decoded route, for display or further checks.
|
|
262
|
+
* @throws StellarRouteVerificationError
|
|
263
|
+
*/ export function verifyStellarRouteBytes(route, expected) {
|
|
264
|
+
const minOut = toAmount(expected.minOut, 'expected.minOut');
|
|
265
|
+
if (minOut <= 0n) fail('EXPECTATION_MISSING', 'expected.minOut must be positive');
|
|
266
|
+
const decoded = decodeStellarRouteBytes(route);
|
|
267
|
+
if (decoded.tokenIn !== expected.tokenIn) {
|
|
268
|
+
fail('TOKEN_IN_MISMATCH', `route spends ${decoded.tokenIn}, expected ${expected.tokenIn}`);
|
|
269
|
+
}
|
|
270
|
+
if (decoded.tokenOut !== expected.tokenOut) {
|
|
271
|
+
fail('TOKEN_OUT_MISMATCH', `route delivers ${decoded.tokenOut}, expected ${expected.tokenOut}`);
|
|
272
|
+
}
|
|
273
|
+
if (decoded.totalMinOut < minOut) {
|
|
274
|
+
fail('MIN_OUT_TOO_LOW', `route enforces a minimum of ${decoded.totalMinOut}, below the expected ${minOut}`);
|
|
275
|
+
}
|
|
276
|
+
if (expected.amountIn !== undefined) {
|
|
277
|
+
const amountIn = toAmount(expected.amountIn, 'expected.amountIn');
|
|
278
|
+
for (const op of decoded.ops){
|
|
279
|
+
if (op.kind === 'swap' && op.mode.kind === 'fixed' && op.tokenIn === decoded.tokenIn && op.mode.amount > amountIn) {
|
|
280
|
+
fail('AMOUNT_IN_EXCEEDED', `a leg spends ${op.mode.amount}, above the input ${amountIn}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return decoded;
|
|
285
|
+
}
|