@keetanetwork/anchor 0.0.68 → 0.0.70

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/chaining.js CHANGED
@@ -11,6 +11,7 @@ import { isExternalChainAsset } from './asset.js';
11
11
  ;
12
12
  ;
13
13
  ;
14
+ ;
14
15
  function areBothTokenAndEqual(a, b) {
15
16
  try {
16
17
  const aParsed = KeetaNet.lib.Account.toAccount(a);
@@ -129,6 +130,11 @@ class AnchorGraph {
129
130
  if (!fromEntries) {
130
131
  return (null);
131
132
  }
133
+ const operations = await service.operations('object');
134
+ if (!operations.createExchange) {
135
+ this.logger?.debug('AnchorGraph::computeFXNodes', `FX service ${providerID} does not support createExchange operation, skipping`);
136
+ return (null);
137
+ }
132
138
  const pathNodes = await Promise.all(fromEntries.map(async function (fromEntry) {
133
139
  const pathNodesResult = [];
134
140
  const parsedEntry = await fromEntry('object');
@@ -224,7 +230,33 @@ class AnchorGraph {
224
230
  if (!isRail(railResolved)) {
225
231
  throw (new Error(`Invalid rail format in extended details: ${railResolved}`));
226
232
  }
227
- return ({ rail: railResolved });
233
+ let supportedOperations;
234
+ if ('supportedOperations' in extendedDetailsResolved && extendedDetailsResolved.supportedOperations) {
235
+ const opsResolved = await extendedDetailsResolved.supportedOperations('object');
236
+ if (opsResolved && typeof opsResolved === 'object' && !Array.isArray(opsResolved)) {
237
+ const parsed = {};
238
+ if ('createPersistentForwarding' in opsResolved && opsResolved.createPersistentForwarding) {
239
+ const val = await opsResolved.createPersistentForwarding('boolean');
240
+ if (typeof val === 'boolean') {
241
+ parsed.createPersistentForwarding = val;
242
+ }
243
+ }
244
+ if ('initiateTransfer' in opsResolved && opsResolved.initiateTransfer) {
245
+ const val = await opsResolved.initiateTransfer('boolean');
246
+ if (typeof val === 'boolean') {
247
+ parsed.initiateTransfer = val;
248
+ }
249
+ }
250
+ if (Object.keys(parsed).length > 0) {
251
+ supportedOperations = parsed;
252
+ }
253
+ }
254
+ }
255
+ const result = { rail: railResolved };
256
+ if (supportedOperations) {
257
+ result.supportedOperations = supportedOperations;
258
+ }
259
+ return (result);
228
260
  }
229
261
  async #computeAssetMovementPairSide(pairSideInput) {
230
262
  const pairSideResolved = await pairSideInput('object');
@@ -242,13 +274,13 @@ class AnchorGraph {
242
274
  const railsResolved = await pairSideResolved.rails('object');
243
275
  const rails = {
244
276
  common: await Promise.all((await railsResolved.common?.('array'))?.map(async (commonInput) => {
245
- return ((await this.#computeAssetRails(commonInput)).rail);
277
+ return (await this.#computeAssetRails(commonInput));
246
278
  }) ?? []),
247
279
  inbound: await Promise.all((await railsResolved.inbound?.('array'))?.map(async (commonInput) => {
248
- return ((await this.#computeAssetRails(commonInput)).rail);
280
+ return (await this.#computeAssetRails(commonInput));
249
281
  }) ?? []),
250
282
  outbound: await Promise.all((await railsResolved.outbound?.('array'))?.map(async (commonInput) => {
251
- return ((await this.#computeAssetRails(commonInput)).rail);
283
+ return (await this.#computeAssetRails(commonInput));
252
284
  }) ?? [])
253
285
  };
254
286
  const id = await pairSideResolved.id('string');
@@ -267,6 +299,7 @@ class AnchorGraph {
267
299
  return ([]);
268
300
  }
269
301
  const providerResults = await Promise.all(Object.entries(assetMovementServices).map(async ([providerID, service]) => {
302
+ const supportedOperationsMetadata = await service.operations('object');
270
303
  const supportedAssetsEntries = await service.supportedAssets('array');
271
304
  if (!supportedAssetsEntries) {
272
305
  this.logger?.debug('AnchorGraph::computeAssetMovementNodes', `No supported assets found for provider ${providerID}`);
@@ -282,18 +315,49 @@ class AnchorGraph {
282
315
  this.#computeAssetMovementPairSide(pairResolved[0]),
283
316
  this.#computeAssetMovementPairSide(pairResolved[1])
284
317
  ]);
318
+ function getProviderSupportedOperationsForRail(railSpecific) {
319
+ const retval = {
320
+ createPersistentForwarding: supportedOperationsMetadata.createPersistentForwarding !== undefined,
321
+ initiateTransfer: supportedOperationsMetadata.initiateTransfer !== undefined
322
+ };
323
+ if (railSpecific) {
324
+ retval.createPersistentForwarding = railSpecific.createPersistentForwarding ?? false;
325
+ retval.initiateTransfer = railSpecific.initiateTransfer ?? false;
326
+ }
327
+ return (retval);
328
+ }
285
329
  const pathNodes = [];
286
330
  for (const [src, dest] of [
287
331
  [fromResolved, toResolved],
288
332
  [toResolved, fromResolved]
289
333
  ]) {
290
334
  for (const inboundRail of [...(src.rails.common ?? []), ...(src.rails.inbound ?? [])]) {
335
+ /*
336
+ * Drop edges whose source rail explicitly cannot
337
+ * initiate a transfer and also cannot create a
338
+ * persistent forwarding address.
339
+ */
340
+ const inboundSupportedOperations = getProviderSupportedOperationsForRail(inboundRail.supportedOperations);
341
+ if (inboundSupportedOperations.initiateTransfer === false && inboundSupportedOperations.createPersistentForwarding === false) {
342
+ this.logger?.debug('AnchorGraph::computeAssetMovementNodes', `Skipping ${providerID} edge from ${convertAssetLocationToString(src.location)} via rail ${inboundRail.rail}: neither initiateTransfer nor createPersistentForwarding supported`);
343
+ continue;
344
+ }
291
345
  for (const outboundRail of [...(dest.rails.common ?? []), ...(dest.rails.outbound ?? [])]) {
292
346
  pathNodes.push({
293
347
  type: 'assetMovement',
294
348
  providerID: providerID,
295
- from: { asset: src.id, location: src.location, rail: inboundRail },
296
- to: { asset: dest.id, location: dest.location, rail: outboundRail }
349
+ from: {
350
+ asset: src.id,
351
+ location: src.location,
352
+ rail: inboundRail.rail,
353
+ supportedOperations: getProviderSupportedOperationsForRail(inboundRail.supportedOperations)
354
+ },
355
+ to: {
356
+ asset: dest.id,
357
+ location: dest.location,
358
+ rail: outboundRail.rail,
359
+ supportedOperations: getProviderSupportedOperationsForRail(outboundRail.supportedOperations)
360
+ }
297
361
  });
298
362
  }
299
363
  }
@@ -580,39 +644,35 @@ class AnchorGraph {
580
644
  return (result.to);
581
645
  }
582
646
  }
583
- async #attachMetadata(assetInfo, options) {
584
- if (!isExternalChainAsset(assetInfo.asset)) {
585
- return (assetInfo);
647
+ async getExternalAssetMetadata(asset, location, providerID) {
648
+ if (!isExternalChainAsset(asset)) {
649
+ return (undefined);
586
650
  }
587
- const providers = await this.getAssetMovementProvidersForAsset(assetInfo.asset, assetInfo.location);
651
+ const providers = await this.getAssetMovementProvidersForAsset(asset, location);
588
652
  if (!providers) {
589
- return (assetInfo);
653
+ return (undefined);
590
654
  }
591
- if (options?.providerID) {
592
- const found = providers[options.providerID];
655
+ if (providerID) {
656
+ const found = providers[providerID];
593
657
  if (!found) {
594
- return (assetInfo);
658
+ return (undefined);
595
659
  }
596
- const metadata = found.provider.getAssetMetadataForLocation(assetInfo.location, assetInfo.asset);
597
- if (!metadata) {
598
- return (assetInfo);
599
- }
600
- return ({
601
- ...assetInfo,
602
- metadata
603
- });
660
+ return (found.provider.getAssetMetadataForLocation(location, asset) ?? undefined);
604
661
  }
605
662
  for (const { provider } of Object.values(providers)) {
606
- const metadata = provider.getAssetMetadataForLocation(assetInfo.location, assetInfo.asset);
607
- if (!metadata) {
608
- continue;
663
+ const metadata = provider.getAssetMetadataForLocation(location, asset);
664
+ if (metadata) {
665
+ return (metadata);
609
666
  }
610
- return ({
611
- ...assetInfo,
612
- metadata
613
- });
614
667
  }
615
- return (assetInfo);
668
+ return (undefined);
669
+ }
670
+ async #attachMetadata(assetInfo, options) {
671
+ const metadata = await this.getExternalAssetMetadata(assetInfo.asset, assetInfo.location, options?.providerID);
672
+ if (!metadata) {
673
+ return (assetInfo);
674
+ }
675
+ return ({ ...assetInfo, metadata });
616
676
  }
617
677
  async resolveAssetsWithMetadata(filter = {}, options) {
618
678
  const result = await this.resolveAssets(filter);
@@ -773,13 +833,118 @@ export class AnchorChainingPlan extends AnchorChainingPath {
773
833
  }
774
834
  return (found);
775
835
  };
836
+ const forwardedSteps = new Map();
837
+ for (let scanIndex = 0; scanIndex < this.path.length; scanIndex++) {
838
+ const scanStep = this.path[scanIndex];
839
+ if (!scanStep || scanStep.type !== 'assetMovement') {
840
+ continue;
841
+ }
842
+ /**
843
+ * PFR is selected in two cases:
844
+ * (a) the source rail explicitly cannot accept a managed transfer.
845
+ * (b) the prior step is also an asset-movement step (AMP -> AMP
846
+ * transition) and the source rail supports PFR.
847
+ */
848
+ const priorStep = scanIndex > 0 ? this.path[scanIndex - 1] : null;
849
+ const isAmpToAmpTransition = priorStep?.type === 'assetMovement';
850
+ const pfrSupported = scanStep.from.supportedOperations?.createPersistentForwarding === true;
851
+ const initiateForbidden = scanStep.from.supportedOperations?.initiateTransfer === false;
852
+ const shouldUsePFR = initiateForbidden || (isAmpToAmpTransition && pfrSupported);
853
+ if (!shouldUsePFR) {
854
+ continue;
855
+ }
856
+ if (!pfrSupported) {
857
+ throw (new Error(`Asset movement provider ${scanStep.providerID} source rail ${scanStep.from.rail} at ${convertAssetLocationToString(scanStep.from.location)} declares initiateTransfer:false but does not support createPersistentForwarding`));
858
+ }
859
+ if (scanIndex !== this.path.length - 1) {
860
+ throw (new Error(`Persistent-forwarding (PersistentForwardingRelay-only) asset movement steps are currently only supported as the last step in a chain (step ${scanIndex} of ${this.path.length})`));
861
+ }
862
+ const destinationAddress = this.request.destination.recipient;
863
+ if (typeof destinationAddress !== 'string') {
864
+ throw (new Error(`Persistent-forwarding step at index ${scanIndex} requires the chain's destination recipient to be a resolved address string`));
865
+ }
866
+ const forwardedAssetPair = { from: scanStep.from.asset, to: scanStep.to.asset };
867
+ const forwardedProviders = await assetMovementClient.getProvidersForTransfer({ asset: forwardedAssetPair, from: scanStep.from.location, to: scanStep.to.location }, { providerIDs: [scanStep.providerID] });
868
+ if (!forwardedProviders?.[0] || forwardedProviders.length === 0) {
869
+ throw (new Error(`Could not get asset movement provider ${scanStep.providerID} for persistent-forwarding step at index ${scanIndex}`));
870
+ }
871
+ const forwardedProvider = forwardedProviders[0];
872
+ if (!await forwardedProvider.isOperationSupported('createPersistentForwarding')) {
873
+ throw (new Error(`Asset movement provider ${scanStep.providerID} does not support createPersistentForwarding, but the source rail ${scanStep.from.rail} at ${convertAssetLocationToString(scanStep.from.location)} requires it (initiateTransfer is unsupported)`));
874
+ }
875
+ if (!await forwardedProvider.isOperationSupported('simulateTransfer')) {
876
+ throw (new Error(`Asset movement provider ${scanStep.providerID} does not support simulateTransfer, which is required to compute valueOut for a persistent-forwarding step at ${convertAssetLocationToString(scanStep.from.location)}`));
877
+ }
878
+ const { signer: forwardedSigner } = await this.getAccountsForAction({
879
+ type: 'assetMovement',
880
+ providerMethod: 'initiateTransfer',
881
+ provider: forwardedProvider
882
+ }, this.#options?.overrides);
883
+ let persistentAddress;
884
+ if (await forwardedProvider.isOperationSupported('listPersistentForwarding')) {
885
+ try {
886
+ const existing = await forwardedProvider.listForwardingAddresses({
887
+ account: forwardedSigner,
888
+ search: [{
889
+ sourceLocation: scanStep.from.location,
890
+ destinationLocation: scanStep.to.location,
891
+ asset: scanStep.from.asset,
892
+ destinationAddress
893
+ }]
894
+ });
895
+ /*
896
+ * Filter to the exact address this step requires.
897
+ */
898
+ const sourceLocationString = convertAssetLocationToString(scanStep.from.location);
899
+ const destLocationString = convertAssetLocationToString(scanStep.to.location);
900
+ const match = existing.addresses.find(addr => {
901
+ if (addr.destinationAddress !== destinationAddress) {
902
+ return (false);
903
+ }
904
+ if (!addr.sourceLocation || convertAssetLocationToString(addr.sourceLocation) !== sourceLocationString) {
905
+ return (false);
906
+ }
907
+ if (!addr.destinationLocation || convertAssetLocationToString(addr.destinationLocation) !== destLocationString) {
908
+ return (false);
909
+ }
910
+ return (true);
911
+ });
912
+ if (match) {
913
+ persistentAddress = match;
914
+ }
915
+ }
916
+ catch (error) {
917
+ this.logger?.debug('AnchorChainingPlan::computePlan', `listForwardingAddresses lookup failed for step ${scanIndex}, will create a new address`, error);
918
+ }
919
+ }
920
+ if (!persistentAddress) {
921
+ persistentAddress = await forwardedProvider.createPersistentForwardingAddress({
922
+ account: forwardedSigner,
923
+ sourceLocation: scanStep.from.location,
924
+ destinationLocation: scanStep.to.location,
925
+ destinationAddress,
926
+ asset: forwardedAssetPair
927
+ });
928
+ }
929
+ if (typeof persistentAddress.address !== 'string') {
930
+ throw (new Error(`Persistent forwarding address for step ${scanIndex} is not a resolved string (got ${typeof persistentAddress.address})`));
931
+ }
932
+ forwardedSteps.set(scanIndex, { provider: forwardedProvider, persistentAddress });
933
+ }
776
934
  const stepPromises = [];
777
935
  const resolvingSteps = new Set();
936
+ const precomputedValueOuts = new Map();
778
937
  const resolveStep = async (index) => {
779
938
  const step = this.path[index];
780
939
  if (!step) {
781
940
  throw (new Error(`Step ${index} is not defined`));
782
941
  }
942
+ /*
943
+ * Detect cycles
944
+ */
945
+ if (resolvingSteps.has(index)) {
946
+ throw (new Error(`Cyclic dependency detected in resolveStep: step ${index} is already being resolved`));
947
+ }
783
948
  let promise = stepPromises[index];
784
949
  if (!promise) {
785
950
  resolvingSteps.add(index);
@@ -817,6 +982,9 @@ export class AnchorChainingPlan extends AnchorChainingPath {
817
982
  throw (new Error(`Could not get FX quote/estimate for provider ${step.providerID}`));
818
983
  }
819
984
  const result = quotesOrEstimates[0];
985
+ if (!result.isQuote && result.estimate.canPerformExchange === false) {
986
+ throw (new Error(`FX estimate from provider ${step.providerID} indicates exchange cannot be performed`));
987
+ }
820
988
  const convertedAmount = result.isQuote ? result.quote.convertedAmount : result.estimate.convertedAmount;
821
989
  let valueIn;
822
990
  let valueOut;
@@ -834,10 +1002,88 @@ export class AnchorChainingPlan extends AnchorChainingPath {
834
1002
  return ({ type: 'fx', step, valueIn, valueOut, result });
835
1003
  }
836
1004
  else if (step.type === 'assetMovement') {
837
- let recipient;
1005
+ if (affinity === 'to') {
1006
+ 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`));
1007
+ }
1008
+ let depositValue;
1009
+ if (index === 0) {
1010
+ depositValue = affinityAndAmount.amount;
1011
+ }
1012
+ else {
1013
+ const precomputedPrev = precomputedValueOuts.get(index - 1);
1014
+ if (precomputedPrev !== undefined) {
1015
+ depositValue = precomputedPrev;
1016
+ }
1017
+ else {
1018
+ const previous = await resolveStep(index - 1);
1019
+ depositValue = previous.valueOut;
1020
+ }
1021
+ }
1022
+ const assetPair = { from: step.from.asset, to: step.to.asset };
1023
+ /*
1024
+ * Forwarded step: prior step deposits into a pre-resolved persistent address.
1025
+ */
1026
+ const forwardedInfo = forwardedSteps.get(index);
1027
+ if (forwardedInfo) {
1028
+ const { provider: forwardedProvider, persistentAddress } = forwardedInfo;
1029
+ /*
1030
+ * Best-effort plan-time valueOut: simulateTransfer if available,
1031
+ * otherwise assume no rail fee.
1032
+ */
1033
+ let estimatedValueOut = depositValue;
1034
+ if (await forwardedProvider.isOperationSupported('simulateTransfer')) {
1035
+ try {
1036
+ const { signer: forwardedSigner } = await this.getAccountsForAction({
1037
+ type: 'assetMovement',
1038
+ providerMethod: 'initiateTransfer',
1039
+ provider: forwardedProvider
1040
+ }, this.#options?.overrides);
1041
+ const simulated = await forwardedProvider.simulateTransfer({
1042
+ account: forwardedSigner,
1043
+ asset: assetPair,
1044
+ from: { location: step.from.location },
1045
+ to: { location: step.to.location },
1046
+ value: depositValue
1047
+ });
1048
+ const simulatedInstruction = simulated.instructions.find((instr) => instr.type === step.from.rail);
1049
+ let simulatedTotalReceive;
1050
+ if (simulatedInstruction) {
1051
+ simulatedTotalReceive = simulatedInstruction.totalReceiveAmount;
1052
+ if (simulatedTotalReceive === undefined && 'value' in simulatedInstruction) {
1053
+ simulatedTotalReceive = simulatedInstruction.value;
1054
+ }
1055
+ }
1056
+ if (simulatedTotalReceive !== undefined) {
1057
+ estimatedValueOut = BigInt(simulatedTotalReceive);
1058
+ }
1059
+ }
1060
+ catch (error) {
1061
+ this.logger?.debug('AnchorChainingPlan::resolveStep', `simulateTransfer for forwarded step ${index} valueOut estimation failed; falling back to depositValue`, error);
1062
+ }
1063
+ }
1064
+ return ({
1065
+ type: 'forwarded',
1066
+ step,
1067
+ valueIn: depositValue,
1068
+ valueOut: estimatedValueOut,
1069
+ persistentAddress,
1070
+ provider: forwardedProvider
1071
+ });
1072
+ }
1073
+ const providers = await assetMovementClient.getProvidersForTransfer({ asset: assetPair, from: step.from.location, to: step.to.location }, { providerIDs: [step.providerID] });
1074
+ if (!providers?.[0] || providers.length === 0) {
1075
+ throw (new Error(`Could not get asset movement provider ${step.providerID}`));
1076
+ }
1077
+ const provider = providers[0];
1078
+ const { signer } = await this.getAccountsForAction({
1079
+ type: 'assetMovement',
1080
+ providerMethod: 'initiateTransfer',
1081
+ provider
1082
+ }, this.#options?.overrides);
1083
+ let resolvedRecipient;
838
1084
  let sendingToType;
839
1085
  if (index === this.path.length - 1) {
840
- recipient = this.request.destination.recipient;
1086
+ resolvedRecipient = this.request.destination.recipient;
841
1087
  sendingToType = 'FINAL_DESTINATION';
842
1088
  }
843
1089
  else {
@@ -846,91 +1092,99 @@ export class AnchorChainingPlan extends AnchorChainingPath {
846
1092
  if (!nextPathStep) {
847
1093
  throw (new Error(`Expected next step at index ${index + 1} for asset movement step at index ${index}`));
848
1094
  }
849
- if (nextPathStep.from.location === `chain:keeta:${this.parent['client'].network}`) {
1095
+ /*
1096
+ * Next step is forwarded: recipient is its persistent address,
1097
+ * no need to resolve the next step's instructions.
1098
+ */
1099
+ const nextForwardedInfo = forwardedSteps.get(index + 1);
1100
+ if (nextForwardedInfo) {
1101
+ const pfiAddress = nextForwardedInfo.persistentAddress.address;
1102
+ if (typeof pfiAddress !== 'string') {
1103
+ throw (new Error(`Persistent forwarding address for next step ${index + 1} is not a resolved string`));
1104
+ }
1105
+ resolvedRecipient = pfiAddress;
1106
+ }
1107
+ else if (nextPathStep.from.location === `chain:keeta:${this.parent['client'].network}`) {
850
1108
  const { account } = await this.getAccountsForAction({
851
1109
  type: 'assetMovement',
852
1110
  providerMethod: 'initiateTransfer'
853
1111
  }, this.#options?.overrides);
854
1112
  // Store funds in-transit in the account instead of forwarding directly to provider.
855
- recipient = account;
1113
+ resolvedRecipient = account;
856
1114
  }
857
1115
  else {
858
- recipient = async () => {
859
- const nextStep = await resolveStep(index + 1);
860
- if (nextStep.type === 'assetMovement' || nextStep.type === 'keetaSend') {
861
- if (nextStep.usingInstruction.type !== step.to.rail) {
862
- throw (new Error(`Next step's usingInstruction type ${nextStep.usingInstruction.type} does not match expected ${step.to.rail} for recipient resolution`));
863
- }
864
- const foundInstruction = nextStep.usingInstruction;
865
- const isFiatPushRailFoundInstruction = (input) => {
866
- return (isFiatRail(input.type));
867
- };
868
- if (foundInstruction.type === 'KEETA_SEND') {
869
- throw (new Error(`Cannot currently chain from asset movement to KEETA_SEND step, as this implies multiple keeta locations in the path which is not currently supported`));
870
- }
871
- else if (isFiatPushRailFoundInstruction(foundInstruction)) {
872
- if (foundInstruction.depositMessage) {
873
- throw (new Error(`Deposit message outbound is not currently supported for chaining`));
874
- }
875
- return (foundInstruction.account);
876
- }
877
- else {
878
- throw (new Error(`Unsupported rail for chaining: ${step.to.rail}`));
1116
+ /**
1117
+ * If the provider does not support simulateTransfer,
1118
+ * we cannot chain to this step.
1119
+ */
1120
+ if (!await provider.isOperationSupported('simulateTransfer')) {
1121
+ throw (new Error(`Asset movement provider ${step.providerID} does not support simulateTransfer, which is required for chaining at non-keeta intermediate location ${convertAssetLocationToString(nextPathStep.from.location)}`));
1122
+ }
1123
+ const simulated = await provider.simulateTransfer({
1124
+ account: signer,
1125
+ asset: assetPair,
1126
+ from: { location: step.from.location },
1127
+ to: { location: step.to.location },
1128
+ value: depositValue
1129
+ });
1130
+ const simulatedInstruction = simulated.instructions.find((instr) => instr.type === step.from.rail);
1131
+ if (!simulatedInstruction) {
1132
+ throw (new Error(`Simulated transfer for step ${index} did not return an instruction matching rail ${step.from.rail}`));
1133
+ }
1134
+ let simulatedTotalReceive = simulatedInstruction.totalReceiveAmount;
1135
+ if (simulatedTotalReceive === undefined && 'value' in simulatedInstruction) {
1136
+ simulatedTotalReceive = simulatedInstruction.value;
1137
+ }
1138
+ if (simulatedTotalReceive === undefined) {
1139
+ throw (new Error(`totalReceiveAmount must be defined for simulated transfer when chaining`));
1140
+ }
1141
+ precomputedValueOuts.set(index, BigInt(simulatedTotalReceive));
1142
+ const nextStep = await resolveStep(index + 1);
1143
+ if (nextStep.type === 'assetMovement' || nextStep.type === 'keetaSend') {
1144
+ if (nextStep.usingInstruction.type !== step.to.rail) {
1145
+ throw (new Error(`Next step's usingInstruction type ${nextStep.usingInstruction.type} does not match expected ${step.to.rail} for recipient resolution`));
1146
+ }
1147
+ const foundInstruction = nextStep.usingInstruction;
1148
+ const isFiatPushRailFoundInstruction = (input) => {
1149
+ return (isFiatRail(input.type));
1150
+ };
1151
+ if (foundInstruction.type === 'KEETA_SEND') {
1152
+ throw (new Error(`Cannot currently chain from asset movement to KEETA_SEND step, as this implies multiple keeta locations in the path which is not currently supported`));
1153
+ }
1154
+ else if (isFiatPushRailFoundInstruction(foundInstruction)) {
1155
+ if (foundInstruction.depositMessage) {
1156
+ throw (new Error(`Deposit message outbound is not currently supported for chaining`));
879
1157
  }
1158
+ resolvedRecipient = foundInstruction.account;
880
1159
  }
881
- else if (nextStep.type === 'fx') {
882
- throw (new Error(`Cannot currently chain from asset movement to fx step, as fx step does not have recipient information`));
1160
+ else if (foundInstruction.type === 'EVM_SEND') {
1161
+ resolvedRecipient = foundInstruction.sendToAddress;
883
1162
  }
884
1163
  else {
885
- assertNever(nextStep);
1164
+ throw (new Error(`Unsupported rail for chaining: ${step.to.rail}`));
886
1165
  }
887
- };
888
- }
889
- }
890
- const assetPair = { from: step.from.asset, to: step.to.asset };
891
- const providers = await assetMovementClient.getProvidersForTransfer({ asset: assetPair, from: step.from.location, to: step.to.location }, { providerIDs: [step.providerID] });
892
- if (!providers?.[0] || providers.length === 0) {
893
- throw (new Error(`Could not get asset movement provider ${step.providerID}`));
894
- }
895
- let depositValue;
896
- if (affinity === 'to') {
897
- 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`));
898
- }
899
- else {
900
- if (index === 0) {
901
- depositValue = affinityAndAmount.amount;
902
- }
903
- else {
904
- const previous = await resolveStep(index - 1);
905
- depositValue = previous.valueOut;
1166
+ }
1167
+ else if (nextStep.type === 'fx') {
1168
+ throw (new Error(`Cannot currently chain from asset movement to fx step, as fx step does not have recipient information`));
1169
+ }
1170
+ else if (nextStep.type === 'forwarded') {
1171
+ throw (new Error(`Internal invariant violation: forwarded step at index ${index + 1} reached simulate-cycle-break path; expected nextForwardedInfo branch to have handled it`));
1172
+ }
1173
+ else {
1174
+ assertNever(nextStep);
1175
+ }
906
1176
  }
907
1177
  }
908
- const { signer } = await this.getAccountsForAction({
909
- type: 'assetMovement',
910
- providerMethod: 'initiateTransfer',
911
- provider: providers[0]
912
- }, this.#options?.overrides);
913
- const transfer = await providers[0].initiateTransfer({
1178
+ const recipientString = KeetaNet.lib.Account.isInstance(resolvedRecipient)
1179
+ ? resolvedRecipient.publicKeyString.get()
1180
+ : resolvedRecipient;
1181
+ const transfer = await provider.initiateTransfer({
914
1182
  account: signer,
915
1183
  asset: assetPair,
916
1184
  from: { location: step.from.location },
917
1185
  to: {
918
1186
  location: step.to.location,
919
- recipient: await (async () => {
920
- let recipientResolved;
921
- if (typeof recipient === 'function') {
922
- recipientResolved = await recipient();
923
- }
924
- else {
925
- recipientResolved = recipient;
926
- }
927
- if (KeetaNet.lib.Account.isInstance(recipientResolved)) {
928
- return (recipientResolved.publicKeyString.get());
929
- }
930
- else {
931
- return (recipientResolved);
932
- }
933
- })()
1187
+ recipient: recipientString
934
1188
  },
935
1189
  value: depositValue
936
1190
  });
@@ -942,6 +1196,14 @@ export class AnchorChainingPlan extends AnchorChainingPath {
942
1196
  if (totalReceiveAmount === undefined) {
943
1197
  throw (new Error(`totalReceiveAmount must be defined for chaining`));
944
1198
  }
1199
+ const actualValueOut = BigInt(totalReceiveAmount);
1200
+ // If we simulated to break a cycle, the next step's initiateTransfer was
1201
+ // keyed off the simulated valueOut; a mismatch here means the next step
1202
+ // is now misaligned, so fail at plan-time instead of letting execute() catch it.
1203
+ const simulatedValueOut = precomputedValueOuts.get(index);
1204
+ if (simulatedValueOut !== undefined && simulatedValueOut !== actualValueOut) {
1205
+ throw (new Error(`Simulated valueOut ${simulatedValueOut} for step ${index} does not match actual ${actualValueOut} from initiateTransfer`));
1206
+ }
945
1207
  return ({
946
1208
  type: 'assetMovement',
947
1209
  step,
@@ -949,7 +1211,7 @@ export class AnchorChainingPlan extends AnchorChainingPath {
949
1211
  usingInstruction: usingInstruction,
950
1212
  transfer: transfer,
951
1213
  sendingTo: sendingToType,
952
- valueOut: BigInt(totalReceiveAmount)
1214
+ valueOut: actualValueOut
953
1215
  });
954
1216
  }
955
1217
  else if (step.type === 'keetaSend') {
@@ -1000,9 +1262,6 @@ export class AnchorChainingPlan extends AnchorChainingPath {
1000
1262
  promise.then(() => resolvingSteps.delete(index), () => resolvingSteps.delete(index));
1001
1263
  stepPromises[index] = promise;
1002
1264
  }
1003
- else if (resolvingSteps.has(index)) {
1004
- throw (new Error(`Cyclic dependency detected in resolveStep: step ${index} is already being resolved`));
1005
- }
1006
1265
  return (await promise);
1007
1266
  };
1008
1267
  const steps = [];
@@ -1140,6 +1399,9 @@ export class AnchorChainingPlan extends AnchorChainingPath {
1140
1399
  const timeoutMs = options?.timeoutMs ?? 300_000;
1141
1400
  const deadline = Date.now() + timeoutMs;
1142
1401
  while (true) {
1402
+ if (options?.abortSignal?.aborted) {
1403
+ throw (new Error(`Aborted while waiting for transfer ${transfer.transferId} to complete`));
1404
+ }
1143
1405
  const status = await transfer.getTransferStatus();
1144
1406
  if (status.transaction.status === 'COMPLETE') {
1145
1407
  return (status);
@@ -1150,11 +1412,61 @@ export class AnchorChainingPlan extends AnchorChainingPath {
1150
1412
  await KeetaNet.lib.Utils.Helper.asleep(intervalMs);
1151
1413
  }
1152
1414
  }
1415
+ /**
1416
+ * Wait for the forwarded transfer the bridge creates after observing the
1417
+ * prior step's withdraw deposit in the persistent-forwarding address.
1418
+ */
1419
+ async #pollForwardedTransaction(step, sourceTransaction, options) {
1420
+ const intervalMs = options?.intervalMs ?? 2000;
1421
+ const timeoutMs = options?.timeoutMs ?? 300_000;
1422
+ const deadline = Date.now() + timeoutMs;
1423
+ const { provider, persistentAddress } = step;
1424
+ const pfiAddress = persistentAddress.address;
1425
+ if (typeof pfiAddress !== 'string') {
1426
+ throw (new Error(`Persistent forwarding address must be a resolved string`));
1427
+ }
1428
+ const { account } = await this.getAccountsForAction({
1429
+ type: 'assetMovement',
1430
+ providerMethod: 'initiateTransfer',
1431
+ provider
1432
+ }, this.#options?.overrides);
1433
+ while (true) {
1434
+ if (options?.abortSignal?.aborted) {
1435
+ throw (new Error(`Aborted while waiting for forwarded transaction at ${pfiAddress} correlated to source tx ${sourceTransaction.transaction.id}`));
1436
+ }
1437
+ let transactions = [];
1438
+ try {
1439
+ const response = await provider.listTransactions({
1440
+ account,
1441
+ persistentAddresses: [{
1442
+ location: step.step.from.location,
1443
+ persistentAddress: pfiAddress
1444
+ }],
1445
+ transactions: [sourceTransaction]
1446
+ });
1447
+ transactions = response.transactions;
1448
+ }
1449
+ catch (error) {
1450
+ this.logger?.debug('AnchorChainingPlan::pollForwardedTransaction', `listTransactions failed for PersistentForwardingRelay address ${pfiAddress}`, error);
1451
+ }
1452
+ const candidate = transactions.find(tx => tx.status === 'COMPLETE');
1453
+ if (candidate) {
1454
+ return (candidate);
1455
+ }
1456
+ if (Date.now() >= deadline) {
1457
+ throw (new Error(`Timed out waiting for persistent-forwarding transaction at ${pfiAddress} correlated to source tx ${sourceTransaction.transaction.id}`));
1458
+ }
1459
+ await KeetaNet.lib.Utils.Helper.asleep(intervalMs);
1460
+ }
1461
+ }
1153
1462
  async #pollExchangeStatus(exchange, options) {
1154
1463
  const intervalMs = options?.intervalMs ?? 2000;
1155
1464
  const timeoutMs = options?.timeoutMs ?? 300_000;
1156
1465
  const deadline = Date.now() + timeoutMs;
1157
1466
  while (true) {
1467
+ if (options?.abortSignal?.aborted) {
1468
+ throw (new Error(`Aborted while waiting for FX exchange ${exchange.exchange.exchangeID} to complete`));
1469
+ }
1158
1470
  const status = await exchange.getExchangeStatus();
1159
1471
  if (status.status === 'completed') {
1160
1472
  return (status);
@@ -1174,11 +1486,23 @@ export class AnchorChainingPlan extends AnchorChainingPath {
1174
1486
  }
1175
1487
  const executedSteps = [];
1176
1488
  this.#setState({ status: 'executing', completedSteps: [], currentStepIndex: 0 });
1177
- // Actual output value from each completed step, used for equality checking.
1489
+ /*
1490
+ * Actual output value from each completed step, used for equality checking.
1491
+ */
1178
1492
  let prevActualValueOut = null;
1493
+ /**
1494
+ * Source-tx anchor for the next forwarded step's poll. Populated only
1495
+ * when the prior step is an asset-movement transfer that produced a
1496
+ * withdraw transaction on its destination chain; reset for any step
1497
+ * type that cannot deposit into a persistent-forwarding address.
1498
+ */
1499
+ let prevWithdrawTx = null;
1179
1500
  let index = 0;
1180
1501
  try {
1181
1502
  for (index = 0; index < this.plan.steps.length; index++) {
1503
+ if (options?.abortSignal?.aborted) {
1504
+ throw (new Error(`Execution aborted`));
1505
+ }
1182
1506
  const onStepCompleted = (step) => {
1183
1507
  executedSteps.push(step);
1184
1508
  this.#emit('stepExecuted', step, index);
@@ -1193,16 +1517,32 @@ export class AnchorChainingPlan extends AnchorChainingPath {
1193
1517
  // different amount than was negotiated in computeSteps.
1194
1518
  if (index > 0 && prevActualValueOut !== null) {
1195
1519
  if (prevActualValueOut !== step.valueIn) {
1196
- throw (new Error(`Value mismatch at step ${index}: ` +
1197
- `expected ${step.valueIn} but previous step produced ${prevActualValueOut}`));
1520
+ if (prevActualValueOut < step.valueIn) {
1521
+ throw (new Error(`Execution failed at step ${index} due to value mismatch: expected at least ${step.valueIn} but previous step produced ${prevActualValueOut}`));
1522
+ }
1523
+ else {
1524
+ this.logger?.debug(`AnchorChainingPlan::execute`, `Value mismatch at step ${index} is non-critical since previous step produced more (${prevActualValueOut}) than expected (${step.valueIn}), proceeding with execution`);
1525
+ }
1198
1526
  }
1199
1527
  }
1200
1528
  if (step.type === 'fx') {
1201
1529
  const exchange = await step.result.createExchange();
1202
1530
  await this.#pollExchangeStatus(exchange);
1203
1531
  prevActualValueOut = step.valueOut;
1532
+ prevWithdrawTx = null;
1204
1533
  onStepCompleted({ type: 'fx', plan: step, exchange });
1205
1534
  }
1535
+ else if (step.type === 'forwarded') {
1536
+ if (!prevWithdrawTx) {
1537
+ throw (new Error(`Forwarded step at index ${index} requires the prior step to produce a withdraw transaction on its destination chain`));
1538
+ }
1539
+ const observed = await this.#pollForwardedTransaction(step, prevWithdrawTx, {
1540
+ ...(options?.abortSignal ? { abortSignal: options.abortSignal } : {})
1541
+ });
1542
+ prevActualValueOut = BigInt(observed.to.value);
1543
+ prevWithdrawTx = null;
1544
+ onStepCompleted({ type: 'forwarded', plan: step, observedTransaction: observed });
1545
+ }
1206
1546
  else if (step.type === 'assetMovement' || step.type === 'keetaSend') {
1207
1547
  if (step.usingInstruction.type === 'KEETA_SEND') {
1208
1548
  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);
@@ -1218,17 +1558,38 @@ export class AnchorChainingPlan extends AnchorChainingPath {
1218
1558
  }
1219
1559
  });
1220
1560
  }
1561
+ else if (step.usingInstruction.type === 'EVM_SEND') {
1562
+ /* For EVM Sends for now we assume the last step sent to this address */
1563
+ this.logger?.debug(`AnchorChainingPlan::execute`, `Executing EVM_SEND instruction for step ${index} by sending to address ${step.usingInstruction.sendToAddress} with value ${step.usingInstruction.value} and token ${step.usingInstruction.tokenAddress}`);
1564
+ }
1221
1565
  else {
1222
1566
  throw (new Error(`Unsupported instruction type ${step.usingInstruction.type} for user-initiated transfer at step ${index}`));
1223
1567
  }
1224
1568
  if (step.type === 'assetMovement') {
1225
- const status = await this.#pollTransferStatus(step.transfer);
1569
+ const status = await this.#pollTransferStatus(step.transfer, {
1570
+ ...(options?.abortSignal ? { abortSignal: options.abortSignal } : {})
1571
+ });
1226
1572
  prevActualValueOut = BigInt(status.transaction.to.value);
1573
+ const withdraw = status.transaction.to.transactions.withdraw;
1574
+ if (withdraw) {
1575
+ prevWithdrawTx = {
1576
+ location: step.step.to.location,
1577
+ transaction: { id: withdraw.id }
1578
+ };
1579
+ }
1580
+ else {
1581
+ prevWithdrawTx = null;
1582
+ }
1227
1583
  onStepCompleted({ type: 'assetMovement', plan: step });
1228
1584
  }
1229
1585
  else if (step.type === 'keetaSend') {
1230
- // 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.
1586
+ /*
1587
+ * Direct Keeta send: optimistically treat as completed since
1588
+ * there is no provider transfer to poll. Cannot feed a forwarded
1589
+ * step because it does not produce a bridge withdraw.
1590
+ */
1231
1591
  prevActualValueOut = step.valueIn;
1592
+ prevWithdrawTx = null;
1232
1593
  onStepCompleted({ type: 'keetaSend', plan: step });
1233
1594
  }
1234
1595
  else {
@@ -1309,6 +1670,20 @@ export class AnchorChaining {
1309
1670
  else {
1310
1671
  foundPaths = await this.graph.findPaths(input);
1311
1672
  }
1673
+ // Filter out paths with non-chain steps in intermediate positions
1674
+ foundPaths = foundPaths?.filter(path => {
1675
+ for (let i = 0; i < path.length - 1; i++) {
1676
+ const item = path[i];
1677
+ if (!item) {
1678
+ continue;
1679
+ }
1680
+ const toLocation = toAssetLocation(item.to.location);
1681
+ if (toLocation.type !== 'chain' && i < path.length - 1) {
1682
+ return (false);
1683
+ }
1684
+ }
1685
+ return (true);
1686
+ });
1312
1687
  if (foundPaths.length === 0) {
1313
1688
  return (null);
1314
1689
  }
@@ -1323,13 +1698,46 @@ export class AnchorChaining {
1323
1698
  if (!paths) {
1324
1699
  return (null);
1325
1700
  }
1326
- const result = await Promise.allSettled(paths.map(async function (path) {
1327
- return (await AnchorChainingPlan.create(path, options));
1328
- }));
1701
+ const limit = options?.limit ?? 3;
1702
+ const sortedPaths = paths.sort((a, b) => a.path.length - b.path.length);
1703
+ let successCount = 0;
1704
+ let lowestStepsSuccessCount = Infinity;
1705
+ let lastAttemptedPathIdx = -1;
1706
+ const maxAttemptLoops = 3;
1707
+ let currentAttemptLoop = 0;
1708
+ const allOutput = [];
1709
+ while (successCount < limit && lastAttemptedPathIdx < sortedPaths.length - 1 && currentAttemptLoop < maxAttemptLoops) {
1710
+ currentAttemptLoop++;
1711
+ const pathsToTry = sortedPaths.slice(lastAttemptedPathIdx + 1, lastAttemptedPathIdx + 1 + (limit - successCount));
1712
+ if (pathsToTry.length === 0 || !pathsToTry[0]) {
1713
+ break;
1714
+ }
1715
+ if (pathsToTry[0].path.length > lowestStepsSuccessCount) {
1716
+ break;
1717
+ }
1718
+ const currentTry = await Promise.allSettled(pathsToTry.map(async function (path) {
1719
+ return (await AnchorChainingPlan.create(path, options));
1720
+ }));
1721
+ allOutput.push(...currentTry);
1722
+ for (let i = 0; i < currentTry.length; i++) {
1723
+ const result = currentTry[i];
1724
+ const path = pathsToTry[i];
1725
+ if (!result || !path) {
1726
+ continue;
1727
+ }
1728
+ if (result.status === 'fulfilled') {
1729
+ successCount++;
1730
+ if (path && path.path.length < lowestStepsSuccessCount) {
1731
+ lowestStepsSuccessCount = path.path.length;
1732
+ }
1733
+ }
1734
+ }
1735
+ lastAttemptedPathIdx += pathsToTry.length;
1736
+ }
1329
1737
  const ret = [];
1330
- for (let i = 0; i < paths.length; i++) {
1331
- const path = paths[i];
1332
- const plan = result[i];
1738
+ for (let i = 0; i < allOutput.length; i++) {
1739
+ const path = sortedPaths[i];
1740
+ const plan = allOutput[i];
1333
1741
  if (!path || !plan) {
1334
1742
  continue;
1335
1743
  }