@keetanetwork/anchor 0.0.52 → 0.0.58
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/lib/asset.d.ts +18 -4
- package/lib/asset.d.ts.map +1 -1
- package/lib/asset.js +18 -0
- package/lib/asset.js.map +1 -1
- package/lib/chaining-graph.cli.d.ts +2 -0
- package/lib/chaining-graph.cli.d.ts.map +1 -0
- package/lib/chaining-graph.cli.js +257 -0
- package/lib/chaining-graph.cli.js.map +1 -0
- package/lib/chaining.d.ts +247 -0
- package/lib/chaining.d.ts.map +1 -0
- package/lib/chaining.js +1187 -0
- package/lib/chaining.js.map +1 -0
- package/lib/metadata.types.d.ts +28 -0
- package/lib/metadata.types.d.ts.map +1 -0
- package/lib/metadata.types.generated.d.ts +3 -0
- package/lib/metadata.types.generated.d.ts.map +1 -0
- package/lib/metadata.types.generated.js +15 -0
- package/lib/metadata.types.generated.js.map +1 -0
- package/lib/metadata.types.js +50 -0
- package/lib/metadata.types.js.map +1 -0
- package/lib/resolver.d.ts +15 -19
- package/lib/resolver.d.ts.map +1 -1
- package/lib/resolver.js +1101 -469
- package/lib/resolver.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/services/asset-movement/client.d.ts +12 -5
- package/services/asset-movement/client.d.ts.map +1 -1
- package/services/asset-movement/client.js +190 -9
- package/services/asset-movement/client.js.map +1 -1
- package/services/asset-movement/common.d.ts +119 -60
- package/services/asset-movement/common.d.ts.map +1 -1
- package/services/asset-movement/common.generated.d.ts +48 -0
- package/services/asset-movement/common.generated.d.ts.map +1 -0
- package/services/asset-movement/common.generated.js +37425 -0
- package/services/asset-movement/common.generated.js.map +1 -0
- package/services/asset-movement/common.js +22 -35368
- package/services/asset-movement/common.js.map +1 -1
- package/services/asset-movement/lib/location.d.ts +10 -1
- package/services/asset-movement/lib/location.d.ts.map +1 -1
- package/services/asset-movement/lib/location.generated.d.ts +2 -1
- package/services/asset-movement/lib/location.generated.d.ts.map +1 -1
- package/services/asset-movement/lib/location.generated.js +23 -0
- package/services/asset-movement/lib/location.generated.js.map +1 -1
- package/services/asset-movement/lib/location.js +3 -0
- package/services/asset-movement/lib/location.js.map +1 -1
- package/services/asset-movement/server.d.ts +17 -6
- package/services/asset-movement/server.d.ts.map +1 -1
- package/services/asset-movement/server.js +47 -2
- package/services/asset-movement/server.js.map +1 -1
- package/services/fx/client.d.ts +3 -2
- package/services/fx/client.d.ts.map +1 -1
- package/services/fx/client.js +8 -3
- package/services/fx/client.js.map +1 -1
- package/services/fx/server.d.ts +2 -1
- package/services/fx/server.d.ts.map +1 -1
- package/services/fx/server.js +3 -0
- package/services/fx/server.js.map +1 -1
- package/services/storage/clients/contacts.generated.js +142 -90
- package/services/storage/clients/contacts.generated.js.map +1 -1
- package/services/storage/common.d.ts +61 -16
- package/services/storage/common.d.ts.map +1 -1
- package/services/storage/common.js.map +1 -1
- package/services/storage/server.d.ts.map +1 -1
- package/services/storage/server.js +35 -22
- package/services/storage/server.js.map +1 -1
- package/services/storage/test-utils.d.ts +7 -2
- package/services/storage/test-utils.d.ts.map +1 -1
- package/services/storage/test-utils.js +22 -4
- package/services/storage/test-utils.js.map +1 -1
package/lib/chaining.js
ADDED
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
import { KeetaNet } from "../client/index.js";
|
|
2
|
+
import { convertAssetLocationToString, convertAssetSearchInputToCanonical, isChainLocation, toAssetLocation } from "../services/asset-movement/common.js";
|
|
3
|
+
import { getDefaultResolver } from '../config.js';
|
|
4
|
+
import { Currency } from '@keetanetwork/currency-info';
|
|
5
|
+
import { isAssetLocationLike } from '../services/asset-movement/lib/location.generated.js';
|
|
6
|
+
import { isFiatRail, isMovableAssetSearchCanonical, isRail } from '../services/asset-movement/common.generated.js';
|
|
7
|
+
import { assertNever } from './utils/never.js';
|
|
8
|
+
import KeetaFXAnchorClient from '../services/fx/client.js';
|
|
9
|
+
import KeetaAssetMovementAnchorClient from '../services/asset-movement/client.js';
|
|
10
|
+
import { isExternalChainAsset } from './asset.js';
|
|
11
|
+
;
|
|
12
|
+
;
|
|
13
|
+
;
|
|
14
|
+
function areBothTokenAndEqual(a, b) {
|
|
15
|
+
try {
|
|
16
|
+
const aParsed = KeetaNet.lib.Account.toAccount(a);
|
|
17
|
+
const bParsed = KeetaNet.lib.Account.toAccount(b);
|
|
18
|
+
if (!aParsed.isToken() || !bParsed.isToken()) {
|
|
19
|
+
return (false);
|
|
20
|
+
}
|
|
21
|
+
return (aParsed.comparePublicKey(bParsed));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return (false);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isAnchorChainingAssetEqual(a, b) {
|
|
28
|
+
if (typeof a === 'string' && typeof b === 'string' && a === b) {
|
|
29
|
+
return (true);
|
|
30
|
+
}
|
|
31
|
+
else if (areBothTokenAndEqual(a, b)) {
|
|
32
|
+
return (true);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
return (false);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function nodeSideSupports(side, required) {
|
|
39
|
+
if (side.rail !== required.rail) {
|
|
40
|
+
return (false);
|
|
41
|
+
}
|
|
42
|
+
if (convertAssetLocationToString(side.location) !== convertAssetLocationToString(required.location)) {
|
|
43
|
+
return (false);
|
|
44
|
+
}
|
|
45
|
+
if (!isAnchorChainingAssetEqual(side.asset, required.asset)) {
|
|
46
|
+
return (false);
|
|
47
|
+
}
|
|
48
|
+
return (true);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Returns true for nodes that keep assets on Keeta: FX nodes, plus
|
|
52
|
+
* asset-movement nodes whose from and to share the same Keeta chain location
|
|
53
|
+
* (custodial FX anchors that don't actually move funds off-chain).
|
|
54
|
+
*/
|
|
55
|
+
function isFXLikeNode(node) {
|
|
56
|
+
if (node.type === 'fx') {
|
|
57
|
+
return (true);
|
|
58
|
+
}
|
|
59
|
+
const fromStr = convertAssetLocationToString(node.from.location);
|
|
60
|
+
const toStr = convertAssetLocationToString(node.to.location);
|
|
61
|
+
return (fromStr === toStr && fromStr.startsWith('chain:keeta:'));
|
|
62
|
+
}
|
|
63
|
+
class AnchorGraph {
|
|
64
|
+
client;
|
|
65
|
+
resolver;
|
|
66
|
+
logger;
|
|
67
|
+
#assetNameCache = new Map();
|
|
68
|
+
constructor(args) {
|
|
69
|
+
this.resolver = args.resolver;
|
|
70
|
+
this.client = args.client;
|
|
71
|
+
this.logger = args.logger;
|
|
72
|
+
}
|
|
73
|
+
async #computeFXNodes() {
|
|
74
|
+
const fxServices = await this.resolver.lookup('fx', {});
|
|
75
|
+
if (!fxServices) {
|
|
76
|
+
return ([]);
|
|
77
|
+
}
|
|
78
|
+
const networkLocation = `chain:keeta:${this.client.network}`;
|
|
79
|
+
const providerLookupResult = await Promise.all(Object.entries(fxServices).map(async ([providerID, service]) => {
|
|
80
|
+
const fromEntries = await service.from('array');
|
|
81
|
+
if (!fromEntries) {
|
|
82
|
+
return (null);
|
|
83
|
+
}
|
|
84
|
+
const pathNodes = await Promise.all(fromEntries.map(async function (fromEntry) {
|
|
85
|
+
const pathNodesResult = [];
|
|
86
|
+
const parsedEntry = await fromEntry('object');
|
|
87
|
+
const [fromCodes, toCodes] = await Promise.all([
|
|
88
|
+
parsedEntry.currencyCodes('array'),
|
|
89
|
+
parsedEntry.to('array')
|
|
90
|
+
]);
|
|
91
|
+
for (const from of fromCodes) {
|
|
92
|
+
const fromResolved = await from('string');
|
|
93
|
+
if (!fromResolved) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const fromAccount = KeetaNet.lib.Account.fromPublicKeyString(fromResolved).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN);
|
|
97
|
+
for (const to of toCodes) {
|
|
98
|
+
const toResolved = await to('string');
|
|
99
|
+
if (!toResolved) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const toAccount = KeetaNet.lib.Account.fromPublicKeyString(toResolved).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN);
|
|
103
|
+
if (fromAccount.comparePublicKey(toAccount)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
pathNodesResult.push({
|
|
107
|
+
type: 'fx',
|
|
108
|
+
providerID: providerID,
|
|
109
|
+
from: { asset: fromAccount, location: networkLocation, rail: 'KEETA_SEND' },
|
|
110
|
+
to: { asset: toAccount, location: networkLocation, rail: 'KEETA_SEND' }
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return (pathNodesResult);
|
|
115
|
+
}));
|
|
116
|
+
return (pathNodes.flat());
|
|
117
|
+
}));
|
|
118
|
+
return (providerLookupResult.flat().filter((node) => !!node));
|
|
119
|
+
}
|
|
120
|
+
async #resolveAssetName(name) {
|
|
121
|
+
if (KeetaNet.lib.Account.isInstance(name) && name.isToken()) {
|
|
122
|
+
return (name);
|
|
123
|
+
}
|
|
124
|
+
if (typeof name === 'string') {
|
|
125
|
+
try {
|
|
126
|
+
return (KeetaNet.lib.Account.fromPublicKeyString(name).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* ignore error and continue with other resolution methods */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
let found = this.#assetNameCache.get(name);
|
|
133
|
+
if (found) {
|
|
134
|
+
return (found);
|
|
135
|
+
}
|
|
136
|
+
if (isExternalChainAsset(name)) {
|
|
137
|
+
found = name;
|
|
138
|
+
}
|
|
139
|
+
else if (Currency.isCurrencyCode(name)) {
|
|
140
|
+
found = name;
|
|
141
|
+
}
|
|
142
|
+
else if (Currency.isISOCurrencyNumber(name)) {
|
|
143
|
+
found = new Currency(name).code;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const lookupRet = await this.resolver.lookupToken(name);
|
|
147
|
+
if (lookupRet) {
|
|
148
|
+
found = KeetaNet.lib.Account.toAccount(lookupRet.token);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!found) {
|
|
152
|
+
throw (new Error(`Unable to resolve asset name: ${name}`));
|
|
153
|
+
}
|
|
154
|
+
this.#assetNameCache.set(name, found);
|
|
155
|
+
return (found);
|
|
156
|
+
}
|
|
157
|
+
async #computeAssetRails(assetInput) {
|
|
158
|
+
try {
|
|
159
|
+
const railResolved = await assetInput('string');
|
|
160
|
+
if (!isRail(railResolved)) {
|
|
161
|
+
throw (new Error(`Invalid rail format: ${railResolved}`));
|
|
162
|
+
}
|
|
163
|
+
return ({ rail: railResolved });
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
/* ignore error */
|
|
167
|
+
}
|
|
168
|
+
const extendedDetailsResolved = await assetInput('object');
|
|
169
|
+
if (!extendedDetailsResolved || typeof extendedDetailsResolved !== 'object' || Array.isArray(extendedDetailsResolved)) {
|
|
170
|
+
throw (new Error(`Invalid asset format, expected string or object with extended details`));
|
|
171
|
+
}
|
|
172
|
+
if (!('rail' in extendedDetailsResolved)) {
|
|
173
|
+
throw (new Error(`Invalid asset format, missing 'rail' field in extended details`));
|
|
174
|
+
}
|
|
175
|
+
const railResolved = await extendedDetailsResolved.rail?.('string');
|
|
176
|
+
if (!isRail(railResolved)) {
|
|
177
|
+
throw (new Error(`Invalid rail format in extended details: ${railResolved}`));
|
|
178
|
+
}
|
|
179
|
+
return ({ rail: railResolved });
|
|
180
|
+
}
|
|
181
|
+
async #computeAssetMovementPairSide(pairSideInput) {
|
|
182
|
+
const pairSideResolved = await pairSideInput('object');
|
|
183
|
+
let location;
|
|
184
|
+
if (pairSideResolved.location) {
|
|
185
|
+
const locationRaw = await pairSideResolved.location('string');
|
|
186
|
+
if (!isAssetLocationLike(locationRaw)) {
|
|
187
|
+
throw (new Error(`Invalid location format: ${locationRaw}`));
|
|
188
|
+
}
|
|
189
|
+
location = locationRaw;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
location = `chain:keeta:${this.client.network}`;
|
|
193
|
+
}
|
|
194
|
+
const railsResolved = await pairSideResolved.rails('object');
|
|
195
|
+
const rails = {
|
|
196
|
+
common: await Promise.all((await railsResolved.common?.('array'))?.map(async (commonInput) => {
|
|
197
|
+
return ((await this.#computeAssetRails(commonInput)).rail);
|
|
198
|
+
}) ?? []),
|
|
199
|
+
inbound: await Promise.all((await railsResolved.inbound?.('array'))?.map(async (commonInput) => {
|
|
200
|
+
return ((await this.#computeAssetRails(commonInput)).rail);
|
|
201
|
+
}) ?? []),
|
|
202
|
+
outbound: await Promise.all((await railsResolved.outbound?.('array'))?.map(async (commonInput) => {
|
|
203
|
+
return ((await this.#computeAssetRails(commonInput)).rail);
|
|
204
|
+
}) ?? [])
|
|
205
|
+
};
|
|
206
|
+
const id = await pairSideResolved.id('string');
|
|
207
|
+
if (!isMovableAssetSearchCanonical(id)) {
|
|
208
|
+
throw (new Error(`Invalid asset id format: ${id}`));
|
|
209
|
+
}
|
|
210
|
+
return ({
|
|
211
|
+
rails: rails,
|
|
212
|
+
location: location,
|
|
213
|
+
id: await this.#resolveAssetName(id)
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async #computeAssetMovementNodes() {
|
|
217
|
+
const assetMovementServices = await this.resolver.lookup('assetMovement', {});
|
|
218
|
+
if (!assetMovementServices) {
|
|
219
|
+
return ([]);
|
|
220
|
+
}
|
|
221
|
+
const providerResults = await Promise.all(Object.entries(assetMovementServices).map(async ([providerID, service]) => {
|
|
222
|
+
const supportedAssetsEntries = await service.supportedAssets('array');
|
|
223
|
+
if (!supportedAssetsEntries) {
|
|
224
|
+
this.logger?.debug('AnchorGraph::computeAssetMovementNodes', `No supported assets found for provider ${providerID}`);
|
|
225
|
+
return (null);
|
|
226
|
+
}
|
|
227
|
+
const pathNodesResult = await Promise.all(supportedAssetsEntries.map(async (assetEntry) => {
|
|
228
|
+
const parsedEntry = await assetEntry('object');
|
|
229
|
+
const pathsResolved = await parsedEntry.paths('array');
|
|
230
|
+
const pathPromises = await Promise.allSettled(pathsResolved.map(async (pathResolvedInput) => {
|
|
231
|
+
const pathResolved = await pathResolvedInput('object');
|
|
232
|
+
const pairResolved = await pathResolved.pair('array');
|
|
233
|
+
const [fromResolved, toResolved] = await Promise.all([
|
|
234
|
+
this.#computeAssetMovementPairSide(pairResolved[0]),
|
|
235
|
+
this.#computeAssetMovementPairSide(pairResolved[1])
|
|
236
|
+
]);
|
|
237
|
+
const pathNodes = [];
|
|
238
|
+
for (const [src, dest] of [
|
|
239
|
+
[fromResolved, toResolved],
|
|
240
|
+
[toResolved, fromResolved]
|
|
241
|
+
]) {
|
|
242
|
+
for (const inboundRail of [...(src.rails.common ?? []), ...(src.rails.inbound ?? [])]) {
|
|
243
|
+
for (const outboundRail of [...(dest.rails.common ?? []), ...(dest.rails.outbound ?? [])]) {
|
|
244
|
+
pathNodes.push({
|
|
245
|
+
type: 'assetMovement',
|
|
246
|
+
providerID: providerID,
|
|
247
|
+
from: { asset: src.id, location: src.location, rail: inboundRail },
|
|
248
|
+
to: { asset: dest.id, location: dest.location, rail: outboundRail }
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return (pathNodes);
|
|
254
|
+
}));
|
|
255
|
+
const allPaths = [];
|
|
256
|
+
for (const resolved of pathPromises) {
|
|
257
|
+
if (resolved.status === 'rejected') {
|
|
258
|
+
this.logger?.debug('AnchorGraph::computeAssetMovementNodes', `error fetching nodes for ... TODO`, resolved.reason);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
allPaths.push(...resolved.value);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return (allPaths);
|
|
265
|
+
}));
|
|
266
|
+
return (pathNodesResult.flat());
|
|
267
|
+
}));
|
|
268
|
+
return (providerResults.flat().filter((node) => !!node));
|
|
269
|
+
}
|
|
270
|
+
async computeGraphNodes() {
|
|
271
|
+
const receivedNodes = await Promise.all([
|
|
272
|
+
this.#computeFXNodes(),
|
|
273
|
+
this.#computeAssetMovementNodes()
|
|
274
|
+
]);
|
|
275
|
+
return (receivedNodes.flat());
|
|
276
|
+
}
|
|
277
|
+
async findPaths(input) {
|
|
278
|
+
const graph = await this.computeGraphNodes();
|
|
279
|
+
const nodesWithNext = graph.map(function (node) {
|
|
280
|
+
return ({ node, next: [] });
|
|
281
|
+
});
|
|
282
|
+
for (const node of nodesWithNext) {
|
|
283
|
+
for (let secondNodeIdx = 0; secondNodeIdx < nodesWithNext.length; secondNodeIdx++) {
|
|
284
|
+
const nodeJ = nodesWithNext[secondNodeIdx];
|
|
285
|
+
if (!nodeJ) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
// We can ignore chaining one fx anchor to itself
|
|
289
|
+
if (node.node.type === 'fx') {
|
|
290
|
+
if (node.node.type === nodeJ.node.type && node.node.providerID === nodeJ.node.providerID) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (nodeSideSupports(node.node.to, nodeJ.node.from)) {
|
|
295
|
+
node.next.push(secondNodeIdx);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const paths = [];
|
|
300
|
+
function getAssetLocationString(input, includeRail = false) {
|
|
301
|
+
let railStr = '';
|
|
302
|
+
if (includeRail) {
|
|
303
|
+
railStr = `#${input.rail}`;
|
|
304
|
+
}
|
|
305
|
+
return (`${convertAssetSearchInputToCanonical(input.asset)}@${convertAssetLocationToString(input.location)}${railStr}`);
|
|
306
|
+
}
|
|
307
|
+
function dfs(currentIndex, target, visitedAssets = new Set(), path = []) {
|
|
308
|
+
const cur = nodesWithNext[currentIndex];
|
|
309
|
+
if (!cur) {
|
|
310
|
+
throw (new Error(`Invalid node index: ${currentIndex}`));
|
|
311
|
+
}
|
|
312
|
+
const assetLocationStr = getAssetLocationString(cur.node.from, true);
|
|
313
|
+
if (visitedAssets.has(assetLocationStr)) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
visitedAssets.add(assetLocationStr);
|
|
317
|
+
const newPath = [...path, cur.node];
|
|
318
|
+
if (nodeSideSupports(cur.node.to, target)) {
|
|
319
|
+
paths.push(newPath);
|
|
320
|
+
}
|
|
321
|
+
for (const nextIndex of nodesWithNext[currentIndex]?.next ?? []) {
|
|
322
|
+
dfs(nextIndex, target, visitedAssets, newPath);
|
|
323
|
+
}
|
|
324
|
+
visitedAssets.delete(assetLocationStr);
|
|
325
|
+
}
|
|
326
|
+
for (let index = 0; index < nodesWithNext.length; index++) {
|
|
327
|
+
const node = nodesWithNext[index];
|
|
328
|
+
if (!node) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (nodeSideSupports(node.node.from, input.source)) {
|
|
332
|
+
dfs(index, input.destination);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return (paths);
|
|
336
|
+
}
|
|
337
|
+
async resolveAssets(filter = {}) {
|
|
338
|
+
const { from: fromFilterInput, to: toFilterInput, maxStepCount, onlyAllowFXLike } = filter;
|
|
339
|
+
const keetaNetworkLocation = `chain:keeta:${this.client.network}`;
|
|
340
|
+
// When onlyAllowFXLike, default omitted locations to the Keeta network location
|
|
341
|
+
const fromFilter = (onlyAllowFXLike && fromFilterInput !== undefined && fromFilterInput.location === undefined)
|
|
342
|
+
? { ...fromFilterInput, location: keetaNetworkLocation }
|
|
343
|
+
: fromFilterInput;
|
|
344
|
+
const toFilter = (onlyAllowFXLike && toFilterInput !== undefined && toFilterInput.location === undefined)
|
|
345
|
+
? { ...toFilterInput, location: keetaNetworkLocation }
|
|
346
|
+
: toFilterInput;
|
|
347
|
+
const nodes = await this.computeGraphNodes();
|
|
348
|
+
// Build forward (next) and backward (prev) adjacency in a single pass.
|
|
349
|
+
const nodesWithAdj = nodes.map(node => ({ node, next: [], prev: [] }));
|
|
350
|
+
for (let i = 0; i < nodesWithAdj.length; i++) {
|
|
351
|
+
for (let j = 0; j < nodesWithAdj.length; j++) {
|
|
352
|
+
const ni = nodesWithAdj[i];
|
|
353
|
+
const nj = nodesWithAdj[j];
|
|
354
|
+
if (!ni || !nj) {
|
|
355
|
+
throw (new Error(`Invalid node index during adjacency construction: ${i} or ${j}`));
|
|
356
|
+
}
|
|
357
|
+
if (ni.node.type === 'fx' && nj.node.type === 'fx' && ni.node.providerID === nj.node.providerID) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (nodeSideSupports(ni.node.to, nj.node.from)) {
|
|
361
|
+
ni.next.push(j);
|
|
362
|
+
nj.prev.push(i);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const assetLocationKey = (side) => {
|
|
367
|
+
return (`${convertAssetSearchInputToCanonical(side.asset)}@${convertAssetLocationToString(side.location)}`);
|
|
368
|
+
};
|
|
369
|
+
const sideMatchesFilter = (side, f) => {
|
|
370
|
+
if (f.location !== undefined && convertAssetLocationToString(side.location) !== convertAssetLocationToString(f.location)) {
|
|
371
|
+
return (false);
|
|
372
|
+
}
|
|
373
|
+
if (f.asset !== undefined && !isAnchorChainingAssetEqual(side.asset, f.asset)) {
|
|
374
|
+
return (false);
|
|
375
|
+
}
|
|
376
|
+
if (f.rail !== undefined && side.rail !== f.rail) {
|
|
377
|
+
return (false);
|
|
378
|
+
}
|
|
379
|
+
return (true);
|
|
380
|
+
};
|
|
381
|
+
// Separate reachable sets and distance maps for backward (from) and forward (to) traversals.
|
|
382
|
+
const fromReachable = new Set();
|
|
383
|
+
const fromDistances = new Map();
|
|
384
|
+
const toReachable = new Set();
|
|
385
|
+
const toDistances = new Map();
|
|
386
|
+
const makeMarkFn = (reachable, distances) => (side, depth) => {
|
|
387
|
+
const key = assetLocationKey(side);
|
|
388
|
+
reachable.add(key);
|
|
389
|
+
if (depth !== undefined) {
|
|
390
|
+
const existing = distances.get(key);
|
|
391
|
+
if (existing === undefined || depth < existing) {
|
|
392
|
+
distances.set(key, depth);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const markFromReachable = makeMarkFn(fromReachable, fromDistances);
|
|
397
|
+
const markToReachable = makeMarkFn(toReachable, toDistances);
|
|
398
|
+
const bfs = (startCondition, adjacency, markSide, markFn) => {
|
|
399
|
+
const nodeVisited = new Set();
|
|
400
|
+
const queue = [];
|
|
401
|
+
for (let i = 0; i < nodesWithAdj.length; i++) {
|
|
402
|
+
const item = nodesWithAdj[i];
|
|
403
|
+
if (!item) {
|
|
404
|
+
throw (new Error(`Invalid node index during BFS initialization: ${i}`));
|
|
405
|
+
}
|
|
406
|
+
if (startCondition(item) && !nodeVisited.has(i)) {
|
|
407
|
+
nodeVisited.add(i);
|
|
408
|
+
queue.push({ nodeIdx: i, depth: 1 });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
while (queue.length > 0) {
|
|
412
|
+
const queueItem = queue.shift();
|
|
413
|
+
if (!queueItem) {
|
|
414
|
+
throw (new Error(`Unexpected empty queue during BFS processing`));
|
|
415
|
+
}
|
|
416
|
+
const { nodeIdx, depth } = queueItem;
|
|
417
|
+
const item = nodesWithAdj[nodeIdx];
|
|
418
|
+
if (!item) {
|
|
419
|
+
throw (new Error(`Invalid node index during BFS processing: ${nodeIdx}`));
|
|
420
|
+
}
|
|
421
|
+
if (onlyAllowFXLike && !isFXLikeNode(item.node)) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
markFn(item.node[markSide], depth);
|
|
425
|
+
if (maxStepCount === undefined || depth < maxStepCount) {
|
|
426
|
+
for (const neighborIdx of item[adjacency]) {
|
|
427
|
+
if (!nodeVisited.has(neighborIdx)) {
|
|
428
|
+
nodeVisited.add(neighborIdx);
|
|
429
|
+
queue.push({ nodeIdx: neighborIdx, depth: depth + 1 });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
if (fromFilter) {
|
|
436
|
+
bfs(item => sideMatchesFilter(item.node.from, fromFilter), 'next', 'to', markToReachable);
|
|
437
|
+
}
|
|
438
|
+
if (toFilter) {
|
|
439
|
+
bfs(item => sideMatchesFilter(item.node.to, toFilter), 'prev', 'from', markFromReachable);
|
|
440
|
+
}
|
|
441
|
+
if (!fromFilter && !toFilter) {
|
|
442
|
+
for (const { node } of nodesWithAdj) {
|
|
443
|
+
if (!onlyAllowFXLike || isFXLikeNode(node)) {
|
|
444
|
+
markFromReachable(node.from);
|
|
445
|
+
markFromReachable(node.to);
|
|
446
|
+
markToReachable(node.from);
|
|
447
|
+
markToReachable(node.to);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Second pass: build result maps by collecting inbound/outbound rails for every reachable
|
|
452
|
+
// (asset, location) pair from ALL graph nodes, not just those on the traversal path.
|
|
453
|
+
const buildResultMap = (reachable, distances) => {
|
|
454
|
+
const resultMap = new Map();
|
|
455
|
+
const getOrCreate = (side) => {
|
|
456
|
+
const key = assetLocationKey(side);
|
|
457
|
+
let resultObj = resultMap.get(key);
|
|
458
|
+
if (!resultObj) {
|
|
459
|
+
const distanceValue = distances.get(key);
|
|
460
|
+
resultObj = {
|
|
461
|
+
asset: side.asset,
|
|
462
|
+
location: side.location,
|
|
463
|
+
rails: { inbound: [], outbound: [] },
|
|
464
|
+
distance: distanceValue !== undefined ? { pathLength: distanceValue } : null
|
|
465
|
+
};
|
|
466
|
+
resultMap.set(key, resultObj);
|
|
467
|
+
}
|
|
468
|
+
return (resultObj);
|
|
469
|
+
};
|
|
470
|
+
for (const { node } of nodesWithAdj) {
|
|
471
|
+
if (onlyAllowFXLike && !isFXLikeNode(node)) {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (reachable.has(assetLocationKey(node.to))) {
|
|
475
|
+
const entry = getOrCreate(node.to);
|
|
476
|
+
if (!entry.rails.inbound.includes(node.to.rail)) {
|
|
477
|
+
entry.rails.inbound.push(node.to.rail);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (reachable.has(assetLocationKey(node.from))) {
|
|
481
|
+
const entry = getOrCreate(node.from);
|
|
482
|
+
if (!entry.rails.outbound.includes(node.from.rail)) {
|
|
483
|
+
entry.rails.outbound.push(node.from.rail);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return (resultMap);
|
|
488
|
+
};
|
|
489
|
+
const fromResultMap = buildResultMap(fromReachable, fromDistances);
|
|
490
|
+
const toResultMap = buildResultMap(toReachable, toDistances);
|
|
491
|
+
// When onlyAllowFXLike, exclude the filter asset from the result set so that
|
|
492
|
+
// "what can USDC be swapped to?" doesn't include USDC itself via a round-trip.
|
|
493
|
+
if (onlyAllowFXLike) {
|
|
494
|
+
if (fromFilter?.asset !== undefined) {
|
|
495
|
+
toResultMap.delete(assetLocationKey({ asset: fromFilter.asset, location: fromFilter.location ?? keetaNetworkLocation }));
|
|
496
|
+
}
|
|
497
|
+
if (toFilter?.asset !== undefined) {
|
|
498
|
+
fromResultMap.delete(assetLocationKey({ asset: toFilter.asset, location: toFilter.location ?? keetaNetworkLocation }));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const filterMap = (map, f, railSide) => Array.from(map.values()).filter(info => {
|
|
502
|
+
if (f.location !== undefined && convertAssetLocationToString(info.location) !== convertAssetLocationToString(f.location)) {
|
|
503
|
+
return (false);
|
|
504
|
+
}
|
|
505
|
+
if (f.asset !== undefined && !isAnchorChainingAssetEqual(info.asset, f.asset)) {
|
|
506
|
+
return (false);
|
|
507
|
+
}
|
|
508
|
+
if (f.rail !== undefined && !info.rails[railSide].includes(f.rail)) {
|
|
509
|
+
return (false);
|
|
510
|
+
}
|
|
511
|
+
return (true);
|
|
512
|
+
});
|
|
513
|
+
const fromAssets = (fromFilter !== undefined && toFilter !== undefined)
|
|
514
|
+
? filterMap(fromResultMap, fromFilter, 'outbound')
|
|
515
|
+
: Array.from(fromResultMap.values());
|
|
516
|
+
const toAssets = (fromFilter !== undefined && toFilter !== undefined)
|
|
517
|
+
? filterMap(toResultMap, toFilter, 'inbound')
|
|
518
|
+
: Array.from(toResultMap.values());
|
|
519
|
+
return ({ from: fromAssets, to: toAssets });
|
|
520
|
+
}
|
|
521
|
+
async listAssets(filter = {}) {
|
|
522
|
+
const result = await this.resolveAssets(filter);
|
|
523
|
+
if (filter.from) {
|
|
524
|
+
return (result.to);
|
|
525
|
+
}
|
|
526
|
+
else if (filter.to) {
|
|
527
|
+
return (result.from);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
return (result.to);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
export class AnchorChainingPath {
|
|
535
|
+
request;
|
|
536
|
+
path;
|
|
537
|
+
parent;
|
|
538
|
+
constructor(input) {
|
|
539
|
+
this.request = input.request;
|
|
540
|
+
this.path = input.path;
|
|
541
|
+
this.parent = input.parent;
|
|
542
|
+
}
|
|
543
|
+
async getAccountForAction(action, overrides) {
|
|
544
|
+
let found;
|
|
545
|
+
if (this.parent['client'].account.isAccount()) {
|
|
546
|
+
found = this.parent['client'].account;
|
|
547
|
+
}
|
|
548
|
+
else if (this.parent['client'].signer !== null) {
|
|
549
|
+
found = this.parent['client'].signer;
|
|
550
|
+
}
|
|
551
|
+
if (overrides?.account) {
|
|
552
|
+
if (typeof overrides.account === 'function') {
|
|
553
|
+
found = await overrides.account(action);
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
found = overrides.account;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return (found);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
export class AnchorChainingPlan extends AnchorChainingPath {
|
|
563
|
+
#_plan = null;
|
|
564
|
+
#state = { status: 'idle' };
|
|
565
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
566
|
+
#listeners = new Map();
|
|
567
|
+
constructor(path) {
|
|
568
|
+
super({ ...path });
|
|
569
|
+
}
|
|
570
|
+
get plan() {
|
|
571
|
+
if (!this.#_plan) {
|
|
572
|
+
throw (new Error(`Steps have not been computed yet`));
|
|
573
|
+
}
|
|
574
|
+
return (this.#_plan);
|
|
575
|
+
}
|
|
576
|
+
async #computePlan(options) {
|
|
577
|
+
if (this.#_plan) {
|
|
578
|
+
throw (new Error(`Steps have already been computed`));
|
|
579
|
+
}
|
|
580
|
+
const sharedClientOptions = {
|
|
581
|
+
resolver: this.parent['resolver'],
|
|
582
|
+
...(this.parent['logger'] ? { logger: this.parent['logger'] } : {})
|
|
583
|
+
};
|
|
584
|
+
const fxClient = new KeetaFXAnchorClient(this.parent['client'], sharedClientOptions);
|
|
585
|
+
const assetMovementClient = new KeetaAssetMovementAnchorClient(this.parent['client'], sharedClientOptions);
|
|
586
|
+
let affinityAndAmount = undefined;
|
|
587
|
+
if (this.request.source.value !== undefined && this.request.destination.value !== undefined) {
|
|
588
|
+
throw (new Error('Must have source.value or destination.value but not both'));
|
|
589
|
+
}
|
|
590
|
+
else if (this.request.source.value !== undefined) {
|
|
591
|
+
affinityAndAmount = {
|
|
592
|
+
affinity: 'from',
|
|
593
|
+
amount: this.request.source.value
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
else if (this.request.destination.value !== undefined) {
|
|
597
|
+
affinityAndAmount = {
|
|
598
|
+
affinity: 'to',
|
|
599
|
+
amount: this.request.destination.value
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
throw (new Error('Must have source.value or destination.value'));
|
|
604
|
+
}
|
|
605
|
+
const { affinity } = affinityAndAmount;
|
|
606
|
+
const findInstruction = (allInstructions, type) => {
|
|
607
|
+
const found = allInstructions.find((instr) => {
|
|
608
|
+
return (instr.type === type);
|
|
609
|
+
});
|
|
610
|
+
if (!found) {
|
|
611
|
+
throw (new Error(`Expected to find instruction of type ${type} in next step's instructions`));
|
|
612
|
+
}
|
|
613
|
+
return (found);
|
|
614
|
+
};
|
|
615
|
+
const stepPromises = [];
|
|
616
|
+
const resolvingSteps = new Set();
|
|
617
|
+
const resolveStep = async (index) => {
|
|
618
|
+
const step = this.path[index];
|
|
619
|
+
if (!step) {
|
|
620
|
+
throw (new Error(`Step ${index} is not defined`));
|
|
621
|
+
}
|
|
622
|
+
let promise = stepPromises[index];
|
|
623
|
+
if (!promise) {
|
|
624
|
+
resolvingSteps.add(index);
|
|
625
|
+
promise = (async () => {
|
|
626
|
+
if (step.type === 'fx') {
|
|
627
|
+
let amount;
|
|
628
|
+
if (affinity === 'from') {
|
|
629
|
+
if (index === 0) {
|
|
630
|
+
amount = affinityAndAmount.amount;
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
const previous = await resolveStep(index - 1);
|
|
634
|
+
amount = previous.valueOut;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
else if (affinity === 'to') {
|
|
638
|
+
if (index === (this.path.length - 1)) {
|
|
639
|
+
// XXX:TODO Move this to destination
|
|
640
|
+
amount = affinityAndAmount.amount;
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
const next = await resolveStep(index + 1);
|
|
644
|
+
amount = next.valueIn;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
assertNever(affinity);
|
|
649
|
+
}
|
|
650
|
+
const quotesOrEstimates = await fxClient.getQuotesOrEstimates({ from: step.from.asset, to: step.to.asset, amount, affinity }, undefined, { providerIDs: [step.providerID] });
|
|
651
|
+
if (!quotesOrEstimates?.[0] || quotesOrEstimates.length === 0) {
|
|
652
|
+
throw (new Error(`Could not get FX quote/estimate for provider ${step.providerID}`));
|
|
653
|
+
}
|
|
654
|
+
const result = quotesOrEstimates[0];
|
|
655
|
+
const convertedAmount = result.isQuote ? result.quote.convertedAmount : result.estimate.convertedAmount;
|
|
656
|
+
let valueIn;
|
|
657
|
+
let valueOut;
|
|
658
|
+
if (affinity === 'to') {
|
|
659
|
+
valueOut = amount;
|
|
660
|
+
valueIn = convertedAmount;
|
|
661
|
+
}
|
|
662
|
+
else if (affinity === 'from') {
|
|
663
|
+
valueOut = convertedAmount;
|
|
664
|
+
valueIn = amount;
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
assertNever(affinity);
|
|
668
|
+
}
|
|
669
|
+
return ({ type: 'fx', step, valueIn, valueOut, result });
|
|
670
|
+
}
|
|
671
|
+
else if (step.type === 'assetMovement') {
|
|
672
|
+
let recipient;
|
|
673
|
+
let sendingToType;
|
|
674
|
+
if (index === this.path.length - 1) {
|
|
675
|
+
recipient = this.request.destination.recipient;
|
|
676
|
+
sendingToType = 'FINAL_DESTINATION';
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
const nextPathStep = this.path[index + 1];
|
|
680
|
+
if (nextPathStep?.type === 'fx') {
|
|
681
|
+
throw (new Error(`Cannot currently chain from asset movement to fx step, as fx step does not have recipient information`));
|
|
682
|
+
}
|
|
683
|
+
const nextStep = await resolveStep(index + 1);
|
|
684
|
+
if (nextStep.type === 'assetMovement' || nextStep.type === 'keetaSend') {
|
|
685
|
+
if (nextStep.usingInstruction.type !== step.to.rail) {
|
|
686
|
+
throw (new Error(`Next step's usingInstruction type ${nextStep.usingInstruction.type} does not match expected ${step.to.rail} for recipient resolution`));
|
|
687
|
+
}
|
|
688
|
+
const foundInstruction = nextStep.usingInstruction;
|
|
689
|
+
const isFiatPushRailFoundInstruction = (input) => {
|
|
690
|
+
return (isFiatRail(input.type));
|
|
691
|
+
};
|
|
692
|
+
if (foundInstruction.type === 'KEETA_SEND') {
|
|
693
|
+
if (!KeetaNet.lib.Account.isInstance(step.to.asset)) {
|
|
694
|
+
throw (new Error(`Expected asset to be a token account for KEETA_SEND rail`));
|
|
695
|
+
}
|
|
696
|
+
if (!step.to.asset.comparePublicKey(foundInstruction.tokenAddress)) {
|
|
697
|
+
throw (new Error(`Recipient token account ${foundInstruction.tokenAddress.toString()} does not match expected ${step.to.asset.publicKeyString.get()}`));
|
|
698
|
+
}
|
|
699
|
+
if (foundInstruction.external) {
|
|
700
|
+
throw (new Error(`Expected KEETA_SEND instruction to not have external value`));
|
|
701
|
+
}
|
|
702
|
+
// XXX:TODO assert value here matches
|
|
703
|
+
sendingToType = 'NEXT_STEP';
|
|
704
|
+
recipient = KeetaNet.lib.Account.fromPublicKeyString(foundInstruction.sendToAddress);
|
|
705
|
+
}
|
|
706
|
+
else if (isFiatPushRailFoundInstruction(foundInstruction)) {
|
|
707
|
+
if (foundInstruction.depositMessage) {
|
|
708
|
+
throw (new Error(`Deposit message outbound is not currently supported for chaining`));
|
|
709
|
+
}
|
|
710
|
+
sendingToType = 'NEXT_STEP';
|
|
711
|
+
recipient = foundInstruction.account;
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
throw (new Error(`Unsupported rail for chaining: ${step.to.rail}`));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
else if (nextStep.type === 'fx') {
|
|
718
|
+
throw (new Error(`Cannot currently chain from asset movement to fx step, as fx step does not have recipient information`));
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
assertNever(nextStep);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (!recipient) {
|
|
725
|
+
throw (new Error(`Recipient must be defined for asset movement step at index ${index}`));
|
|
726
|
+
}
|
|
727
|
+
const assetPair = { from: step.from.asset, to: step.to.asset };
|
|
728
|
+
const providers = await assetMovementClient.getProvidersForTransfer({ asset: assetPair, from: step.from.location, to: step.to.location }, { providerIDs: [step.providerID] });
|
|
729
|
+
if (!providers?.[0] || providers.length === 0) {
|
|
730
|
+
throw (new Error(`Could not get asset movement provider ${step.providerID}`));
|
|
731
|
+
}
|
|
732
|
+
let depositValue;
|
|
733
|
+
if (affinity === 'to') {
|
|
734
|
+
throw (new Error(`Chaining with affinity 'to' is not currently supported for asset movement steps, as it requires looking up transfer quotes/estimates which is not currently implemented`));
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
if (index === 0) {
|
|
738
|
+
depositValue = affinityAndAmount.amount;
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
const previous = await resolveStep(index - 1);
|
|
742
|
+
depositValue = previous.valueOut;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const transfer = await providers[0].initiateTransfer({
|
|
746
|
+
account: await this.getAccountForAction({
|
|
747
|
+
type: 'assetMovement',
|
|
748
|
+
providerMethod: 'initiateTransfer',
|
|
749
|
+
provider: providers[0]
|
|
750
|
+
}, options?.overrides),
|
|
751
|
+
asset: assetPair,
|
|
752
|
+
from: { location: step.from.location },
|
|
753
|
+
to: {
|
|
754
|
+
location: step.to.location,
|
|
755
|
+
recipient: (() => {
|
|
756
|
+
if (KeetaNet.lib.Account.isInstance(recipient)) {
|
|
757
|
+
return (recipient.publicKeyString.get());
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
return (recipient);
|
|
761
|
+
}
|
|
762
|
+
})()
|
|
763
|
+
},
|
|
764
|
+
value: depositValue
|
|
765
|
+
});
|
|
766
|
+
const usingInstruction = findInstruction(transfer.instructions, step.from.rail);
|
|
767
|
+
if (!usingInstruction.totalReceiveAmount) {
|
|
768
|
+
throw (new Error(`totalReceiveAmount must be defined for chaining`));
|
|
769
|
+
}
|
|
770
|
+
return ({
|
|
771
|
+
type: 'assetMovement',
|
|
772
|
+
step,
|
|
773
|
+
valueIn: depositValue,
|
|
774
|
+
usingInstruction: usingInstruction,
|
|
775
|
+
transfer: transfer,
|
|
776
|
+
sendingTo: sendingToType,
|
|
777
|
+
valueOut: BigInt(usingInstruction.totalReceiveAmount)
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
else if (step.type === 'keetaSend') {
|
|
781
|
+
if (this.path.length !== 1) {
|
|
782
|
+
throw (new Error(`Direct same-location/same-asset send steps must be the only step in the path`));
|
|
783
|
+
}
|
|
784
|
+
if (!KeetaNet.lib.Account.isInstance(step.from.asset) || !KeetaNet.lib.Account.isInstance(step.to.asset)) {
|
|
785
|
+
throw (new Error(`Expected assets to be token accounts for KEETA_SEND rail`));
|
|
786
|
+
}
|
|
787
|
+
if (!step.from.asset.comparePublicKey(step.to.asset)) {
|
|
788
|
+
throw (new Error(`For KEETA_SEND step, from and to asset must be the same account`));
|
|
789
|
+
}
|
|
790
|
+
let keetaRecipientDestination = null;
|
|
791
|
+
if (KeetaNet.lib.Account.isInstance(this.request.destination.recipient)) {
|
|
792
|
+
keetaRecipientDestination = this.request.destination.recipient;
|
|
793
|
+
}
|
|
794
|
+
else if (typeof this.request.destination.recipient === 'string') {
|
|
795
|
+
try {
|
|
796
|
+
keetaRecipientDestination = KeetaNet.lib.Account.fromPublicKeyString(this.request.destination.recipient);
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
/* ignore errors */
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (!keetaRecipientDestination) {
|
|
803
|
+
throw (new Error(`Expected destination recipient to be a public key string for KEETA_SEND step`));
|
|
804
|
+
}
|
|
805
|
+
return ({
|
|
806
|
+
type: 'keetaSend',
|
|
807
|
+
step: null,
|
|
808
|
+
valueIn: affinityAndAmount.amount,
|
|
809
|
+
valueOut: affinityAndAmount.amount,
|
|
810
|
+
usingInstruction: {
|
|
811
|
+
type: 'KEETA_SEND',
|
|
812
|
+
tokenAddress: step.to.asset.publicKeyString.get(),
|
|
813
|
+
sendToAddress: keetaRecipientDestination.publicKeyString.get(),
|
|
814
|
+
totalReceiveAmount: affinityAndAmount.amount.toString(),
|
|
815
|
+
location: `chain:keeta:${this.parent['client'].network}`,
|
|
816
|
+
value: String(affinityAndAmount.amount),
|
|
817
|
+
assetFee: '0'
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
assertNever(step);
|
|
823
|
+
}
|
|
824
|
+
})();
|
|
825
|
+
promise.then(() => resolvingSteps.delete(index), () => resolvingSteps.delete(index));
|
|
826
|
+
stepPromises[index] = promise;
|
|
827
|
+
}
|
|
828
|
+
else if (resolvingSteps.has(index)) {
|
|
829
|
+
throw (new Error(`Cyclic dependency detected in resolveStep: step ${index} is already being resolved`));
|
|
830
|
+
}
|
|
831
|
+
return (await promise);
|
|
832
|
+
};
|
|
833
|
+
const steps = [];
|
|
834
|
+
for (let index = 0; index < this.path.length; index++) {
|
|
835
|
+
steps.push(await resolveStep(index));
|
|
836
|
+
}
|
|
837
|
+
// Direct same-location/same-asset send: no provider steps needed.
|
|
838
|
+
if (steps.length === 0) {
|
|
839
|
+
return ({
|
|
840
|
+
steps: [],
|
|
841
|
+
totalValueIn: affinityAndAmount.amount,
|
|
842
|
+
totalValueOut: affinityAndAmount.amount
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
const firstStep = steps[0];
|
|
846
|
+
const lastStep = steps[steps.length - 1];
|
|
847
|
+
if (!firstStep || !lastStep) {
|
|
848
|
+
throw (new Error(`Steps array is empty`));
|
|
849
|
+
}
|
|
850
|
+
if (affinity === 'from') {
|
|
851
|
+
if (firstStep.valueIn !== this.request.source.value) {
|
|
852
|
+
throw (new Error(`Computed valueIn for first step ${firstStep.valueIn} does not match request source value ${this.request.source.value}`));
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
else if (affinity === 'to') {
|
|
856
|
+
if (lastStep.valueOut !== this.request.destination.value) {
|
|
857
|
+
throw (new Error(`Computed valueOut for last step ${lastStep.valueOut} does not match requested destination value ${this.request.destination.value}`));
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (lastStep.valueOut <= 0n) {
|
|
861
|
+
throw (new Error(`Computed valueOut for last step must be greater than 0, got ${lastStep.valueOut}`));
|
|
862
|
+
}
|
|
863
|
+
return ({
|
|
864
|
+
steps,
|
|
865
|
+
totalValueIn: firstStep.valueIn,
|
|
866
|
+
totalValueOut: lastStep.valueOut
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
static async create(path, options) {
|
|
870
|
+
const instance = new this(path);
|
|
871
|
+
instance.#_plan = await instance.#computePlan(options);
|
|
872
|
+
return (instance);
|
|
873
|
+
}
|
|
874
|
+
get state() {
|
|
875
|
+
return (this.#state);
|
|
876
|
+
}
|
|
877
|
+
on(event, listener) {
|
|
878
|
+
let listenerSet = this.#listeners.get(event);
|
|
879
|
+
if (!listenerSet) {
|
|
880
|
+
listenerSet = new Set();
|
|
881
|
+
this.#listeners.set(event, listenerSet);
|
|
882
|
+
}
|
|
883
|
+
listenerSet.add(listener);
|
|
884
|
+
}
|
|
885
|
+
off(event, listener) {
|
|
886
|
+
this.#listeners.get(event)?.delete(listener);
|
|
887
|
+
}
|
|
888
|
+
#setState(state) {
|
|
889
|
+
this.#state = state;
|
|
890
|
+
this.#emit('stateChange', state);
|
|
891
|
+
}
|
|
892
|
+
get logger() {
|
|
893
|
+
return (this.parent['logger']);
|
|
894
|
+
}
|
|
895
|
+
#emit(event, ...args) {
|
|
896
|
+
let sendCount = 0;
|
|
897
|
+
for (const listener of (this.#listeners.get(event) ?? [])) {
|
|
898
|
+
try {
|
|
899
|
+
listener(...args);
|
|
900
|
+
sendCount++;
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
this.logger?.debug(`AnchorChainingPath::emit`, `Error in listener for event '${event}'`, err);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return ({ sendCount });
|
|
907
|
+
}
|
|
908
|
+
async #awaitStepCompletion(step) {
|
|
909
|
+
let didComplete = false;
|
|
910
|
+
function assertDidNotComplete() {
|
|
911
|
+
if (didComplete) {
|
|
912
|
+
throw (new Error(`Step was already marked as completed or failed`));
|
|
913
|
+
}
|
|
914
|
+
didComplete = true;
|
|
915
|
+
}
|
|
916
|
+
let resolveFn;
|
|
917
|
+
let rejectFn;
|
|
918
|
+
const promise = new Promise(function (resolve, reject) {
|
|
919
|
+
resolveFn = (...args) => {
|
|
920
|
+
assertDidNotComplete();
|
|
921
|
+
resolve(args);
|
|
922
|
+
};
|
|
923
|
+
rejectFn = (error) => {
|
|
924
|
+
assertDidNotComplete();
|
|
925
|
+
let usingErr = error;
|
|
926
|
+
if (!usingErr) {
|
|
927
|
+
usingErr = new Error(`Step marked as failed without error`);
|
|
928
|
+
}
|
|
929
|
+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
|
930
|
+
reject(usingErr);
|
|
931
|
+
};
|
|
932
|
+
});
|
|
933
|
+
if (!resolveFn || !rejectFn) {
|
|
934
|
+
throw (new Error(`Failed to create step completion promise`));
|
|
935
|
+
}
|
|
936
|
+
// Typescript Cannot infer the correct payload type for the stepNeedsAction event, so we have to assert it here. We ensure type safety by constraining the step parameter to the correct action type, which guarantees that the payload will match the expected structure for that action type.
|
|
937
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
938
|
+
const { sendCount } = this.#emit('stepNeedsAction', {
|
|
939
|
+
...step,
|
|
940
|
+
markCompleted: resolveFn,
|
|
941
|
+
markFailed: rejectFn
|
|
942
|
+
});
|
|
943
|
+
if (sendCount === 0) {
|
|
944
|
+
throw (new Error(`No listeners for stepNeedsAction event, but a step (actionType=${step.type}) is awaiting completion`));
|
|
945
|
+
}
|
|
946
|
+
return (await promise);
|
|
947
|
+
}
|
|
948
|
+
async #authorizedSend(options, sendToAddress, value, token, external) {
|
|
949
|
+
if (options?.requireSendAuth) {
|
|
950
|
+
await this.#awaitStepCompletion({
|
|
951
|
+
type: 'keetaSendAuthRequired',
|
|
952
|
+
action: {
|
|
953
|
+
sendToAddress: KeetaNet.lib.Account.toAccount(sendToAddress),
|
|
954
|
+
value,
|
|
955
|
+
token: KeetaNet.lib.Account.toAccount(token).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN),
|
|
956
|
+
...(external !== undefined ? { external } : {})
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
await this.parent['client'].send(sendToAddress, value, token, external);
|
|
961
|
+
}
|
|
962
|
+
async #pollTransferStatus(transfer, options) {
|
|
963
|
+
const intervalMs = options?.intervalMs ?? 2000;
|
|
964
|
+
const timeoutMs = options?.timeoutMs ?? 300_000;
|
|
965
|
+
const deadline = Date.now() + timeoutMs;
|
|
966
|
+
while (true) {
|
|
967
|
+
const status = await transfer.getTransferStatus();
|
|
968
|
+
if (status.transaction.status === 'COMPLETED') {
|
|
969
|
+
return (status);
|
|
970
|
+
}
|
|
971
|
+
if (Date.now() >= deadline) {
|
|
972
|
+
throw (new Error(`Timed out waiting for transfer ${transfer.transferId} to complete`));
|
|
973
|
+
}
|
|
974
|
+
await KeetaNet.lib.Utils.Helper.asleep(intervalMs);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
async #pollExchangeStatus(exchange, options) {
|
|
978
|
+
const intervalMs = options?.intervalMs ?? 2000;
|
|
979
|
+
const timeoutMs = options?.timeoutMs ?? 300_000;
|
|
980
|
+
const deadline = Date.now() + timeoutMs;
|
|
981
|
+
while (true) {
|
|
982
|
+
const status = await exchange.getExchangeStatus();
|
|
983
|
+
if (status.status === 'completed') {
|
|
984
|
+
return (status);
|
|
985
|
+
}
|
|
986
|
+
if (status.status === 'failed') {
|
|
987
|
+
throw (new Error(`FX exchange ${exchange.exchange.exchangeID} failed`));
|
|
988
|
+
}
|
|
989
|
+
if (Date.now() >= deadline) {
|
|
990
|
+
throw (new Error(`Timed out waiting for FX exchange ${exchange.exchange.exchangeID} to complete`));
|
|
991
|
+
}
|
|
992
|
+
await KeetaNet.lib.Utils.Helper.asleep(intervalMs);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
async execute(options) {
|
|
996
|
+
if (this.#state.status !== 'idle') {
|
|
997
|
+
throw (new Error(`Cannot execute: path is already in state "${this.#state.status}"`));
|
|
998
|
+
}
|
|
999
|
+
const executedSteps = [];
|
|
1000
|
+
this.#setState({ status: 'executing', completedSteps: [], currentStepIndex: 0 });
|
|
1001
|
+
// Actual output value from each completed step, used for equality checking.
|
|
1002
|
+
let prevActualValueOut = null;
|
|
1003
|
+
let index = 0;
|
|
1004
|
+
try {
|
|
1005
|
+
let prev = null;
|
|
1006
|
+
for (index = 0; index < this.plan.steps.length; index++) {
|
|
1007
|
+
const onStepCompleted = (step) => {
|
|
1008
|
+
executedSteps.push(step);
|
|
1009
|
+
this.#emit('stepExecuted', step, index);
|
|
1010
|
+
};
|
|
1011
|
+
this.#setState({ status: 'executing', completedSteps: [...executedSteps], currentStepIndex: index });
|
|
1012
|
+
const step = this.plan.steps[index];
|
|
1013
|
+
if (!step) {
|
|
1014
|
+
throw (new Error(`Step ${index} is not defined`));
|
|
1015
|
+
}
|
|
1016
|
+
// Verify the actual output from the previous step matches the expected
|
|
1017
|
+
// input for this step. A mismatch indicates a provider delivered a
|
|
1018
|
+
// different amount than was negotiated in computeSteps.
|
|
1019
|
+
if (index > 0 && prevActualValueOut !== null) {
|
|
1020
|
+
if (prevActualValueOut !== step.valueIn) {
|
|
1021
|
+
throw (new Error(`Value mismatch at step ${index}: ` +
|
|
1022
|
+
`expected ${step.valueIn} but previous step produced ${prevActualValueOut}`));
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (step.type === 'fx') {
|
|
1026
|
+
const exchange = await step.result.createExchange();
|
|
1027
|
+
await this.#pollExchangeStatus(exchange);
|
|
1028
|
+
prevActualValueOut = step.valueOut;
|
|
1029
|
+
onStepCompleted({ type: 'fx', plan: step, exchange });
|
|
1030
|
+
}
|
|
1031
|
+
else if (step.type === 'assetMovement' || step.type === 'keetaSend') {
|
|
1032
|
+
let userInitiatedTransferRequired;
|
|
1033
|
+
if (prev && prev.type === 'assetMovement') {
|
|
1034
|
+
if (prev.sendingTo === 'NEXT_STEP') {
|
|
1035
|
+
userInitiatedTransferRequired = false;
|
|
1036
|
+
}
|
|
1037
|
+
else if (prev.sendingTo === 'FINAL_DESTINATION') {
|
|
1038
|
+
throw (new Error(`Invalid path: step ${index - 1} is sending to final destination, but is followed by another step`));
|
|
1039
|
+
}
|
|
1040
|
+
else if (prev.sendingTo === 'SELF') {
|
|
1041
|
+
userInitiatedTransferRequired = true;
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
assertNever(prev.sendingTo);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
else {
|
|
1048
|
+
userInitiatedTransferRequired = true;
|
|
1049
|
+
}
|
|
1050
|
+
if (userInitiatedTransferRequired) {
|
|
1051
|
+
if (step.usingInstruction.type === 'KEETA_SEND') {
|
|
1052
|
+
await this.#authorizedSend(options, step.usingInstruction.sendToAddress, BigInt(step.usingInstruction.value), KeetaNet.lib.Account.fromPublicKeyString(step.usingInstruction.tokenAddress).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN), step.usingInstruction.external);
|
|
1053
|
+
}
|
|
1054
|
+
else if (index === 0) {
|
|
1055
|
+
if (step.type !== 'assetMovement') {
|
|
1056
|
+
throw (new Error(`Unexpected asset movement step at index ${index} for user-initiated transfer`));
|
|
1057
|
+
}
|
|
1058
|
+
await this.#awaitStepCompletion({
|
|
1059
|
+
type: 'assetMovementUserExecutionRequired',
|
|
1060
|
+
action: {
|
|
1061
|
+
assetMovementTransfer: step.transfer
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
else {
|
|
1066
|
+
throw (new Error(`Unsupported instruction type ${step.usingInstruction.type} for user-initiated transfer at step ${index}`));
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
if (step.type === 'assetMovement') {
|
|
1070
|
+
const status = await this.#pollTransferStatus(step.transfer);
|
|
1071
|
+
prevActualValueOut = BigInt(status.transaction.to.value);
|
|
1072
|
+
onStepCompleted({ type: 'assetMovement', plan: step });
|
|
1073
|
+
}
|
|
1074
|
+
else if (step.type === 'keetaSend') {
|
|
1075
|
+
// For a direct Keeta send, we don't have a transfer object to poll, so we optimistically assume it completes successfully after the authorized send. We could optionally add a polling mechanism here if the underlying client provides a way to check the status of a Keeta transfer.
|
|
1076
|
+
prevActualValueOut = step.valueIn;
|
|
1077
|
+
onStepCompleted({ type: 'keetaSend', plan: step });
|
|
1078
|
+
}
|
|
1079
|
+
else {
|
|
1080
|
+
assertNever(step);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
assertNever(step);
|
|
1085
|
+
}
|
|
1086
|
+
prev = step;
|
|
1087
|
+
}
|
|
1088
|
+
// Direct same-location/same-asset send: the loop ran zero iterations,
|
|
1089
|
+
// so just publish the on-chain transfer directly.
|
|
1090
|
+
if (this.path.length === 0) {
|
|
1091
|
+
const sendValue = this.request.source.value ?? this.request.destination.value;
|
|
1092
|
+
if (!sendValue) {
|
|
1093
|
+
throw (new Error(`Direct send requires a value for source or destination`));
|
|
1094
|
+
}
|
|
1095
|
+
if (!KeetaNet.lib.Account.isInstance(this.request.source.asset)) {
|
|
1096
|
+
throw (new Error(`Direct send requires a Keeta token address as the source asset`));
|
|
1097
|
+
}
|
|
1098
|
+
const recipient = this.request.destination.recipient;
|
|
1099
|
+
if (typeof recipient !== 'string') {
|
|
1100
|
+
throw (new Error(`Direct Keeta send requires a crypto address as the recipient`));
|
|
1101
|
+
}
|
|
1102
|
+
await this.#authorizedSend(options, recipient, sendValue, this.request.source.asset);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1107
|
+
this.#setState({ status: 'failed', error, completedSteps: [...executedSteps], failedAtStepIndex: index });
|
|
1108
|
+
this.#emit('failed', error, [...executedSteps], index);
|
|
1109
|
+
throw (error);
|
|
1110
|
+
}
|
|
1111
|
+
const result = { steps: executedSteps };
|
|
1112
|
+
this.#setState({ status: 'completed', result });
|
|
1113
|
+
this.#emit('completed', result);
|
|
1114
|
+
return (result);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
export class AnchorChaining {
|
|
1118
|
+
client;
|
|
1119
|
+
resolver;
|
|
1120
|
+
graph;
|
|
1121
|
+
logger;
|
|
1122
|
+
constructor(config) {
|
|
1123
|
+
this.client = config.client;
|
|
1124
|
+
if (config.resolver) {
|
|
1125
|
+
this.resolver = config.resolver;
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
this.resolver = getDefaultResolver(config.client);
|
|
1129
|
+
}
|
|
1130
|
+
this.graph = new AnchorGraph({ resolver: this.resolver, client: this.client, logger: config.logger });
|
|
1131
|
+
if (config.logger !== undefined) {
|
|
1132
|
+
this.logger = config.logger;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
async getPaths(input) {
|
|
1136
|
+
// Direct send: same Keeta location, same asset, same rail no providers needed.
|
|
1137
|
+
const sourceLocation = toAssetLocation(input.source.location);
|
|
1138
|
+
const destinationLocation = toAssetLocation(input.destination.location);
|
|
1139
|
+
let foundPaths = null;
|
|
1140
|
+
if (input.source.rail === 'KEETA_SEND' &&
|
|
1141
|
+
input.destination.rail === 'KEETA_SEND' &&
|
|
1142
|
+
convertAssetLocationToString(sourceLocation) === convertAssetLocationToString(destinationLocation) &&
|
|
1143
|
+
isChainLocation(sourceLocation, 'keeta') &&
|
|
1144
|
+
isChainLocation(destinationLocation, 'keeta') &&
|
|
1145
|
+
isAnchorChainingAssetEqual(input.source.asset, input.destination.asset)) {
|
|
1146
|
+
const fromTo = {
|
|
1147
|
+
asset: input.source.asset,
|
|
1148
|
+
location: sourceLocation,
|
|
1149
|
+
rail: 'KEETA_SEND'
|
|
1150
|
+
};
|
|
1151
|
+
foundPaths = [
|
|
1152
|
+
[{ type: 'keetaSend', from: fromTo, to: fromTo }]
|
|
1153
|
+
];
|
|
1154
|
+
}
|
|
1155
|
+
else {
|
|
1156
|
+
foundPaths = await this.graph.findPaths(input);
|
|
1157
|
+
}
|
|
1158
|
+
if (foundPaths.length === 0) {
|
|
1159
|
+
return (null);
|
|
1160
|
+
}
|
|
1161
|
+
const retval = [];
|
|
1162
|
+
for (const path of foundPaths) {
|
|
1163
|
+
retval.push(new AnchorChainingPath({ request: input, path, parent: this }));
|
|
1164
|
+
}
|
|
1165
|
+
return (retval);
|
|
1166
|
+
}
|
|
1167
|
+
async getPlans(input, options) {
|
|
1168
|
+
const paths = await this.getPaths(input);
|
|
1169
|
+
if (!paths) {
|
|
1170
|
+
return (null);
|
|
1171
|
+
}
|
|
1172
|
+
const result = await Promise.allSettled(paths.map(async function (path) {
|
|
1173
|
+
return (await AnchorChainingPlan.create(path, options));
|
|
1174
|
+
}));
|
|
1175
|
+
const ret = [];
|
|
1176
|
+
for (const plan of result) {
|
|
1177
|
+
if (plan.status === 'fulfilled') {
|
|
1178
|
+
ret.push(plan.value);
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
this.logger?.debug(`AnchorChaining::getPlans`, `Error computing plan for a path:`, plan.reason);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return (ret);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
//# sourceMappingURL=chaining.js.map
|