diff --git a/packages/extension/src/libs/activity-state/index.ts b/packages/extension/src/libs/activity-state/index.ts index ff9c339c8..735c91e29 100644 --- a/packages/extension/src/libs/activity-state/index.ts +++ b/packages/extension/src/libs/activity-state/index.ts @@ -61,6 +61,7 @@ class ActivityState { this.getActivityId(options), ); } + async updateActivity( activity: Activity, options: ActivityOptions, @@ -75,11 +76,13 @@ class ActivityState { }); await this.setActivitiesById(clone, this.getActivityId(options)); } + async setCacheTime(options: ActivityOptions): Promise { await this.#storage.set(this.getActivityCacheId(options), { [STORAGE_KEY]: new Date().getTime(), }); } + async getCacheTime(options: ActivityOptions): Promise { const cacheTime: Record = await this.#storage.get( this.getActivityCacheId(options), @@ -87,12 +90,15 @@ class ActivityState { if (!cacheTime || !cacheTime[STORAGE_KEY]) return 0; return cacheTime[STORAGE_KEY]; } + async getAllActivities(options: ActivityOptions): Promise { return this.getActivitiesById(this.getActivityId(options)); } + async deleteAllActivities(options: ActivityOptions): Promise { this.setActivitiesById([], this.getActivityId(options)); } + private async setActivitiesById( activities: Activity[], id: string, @@ -101,6 +107,7 @@ class ActivityState { [STORAGE_KEY]: activities, }); } + private async getActivitiesById(id: string): Promise { const allStates: Record = await this.#storage.get(id); if (!allStates || !allStates[STORAGE_KEY]) return []; diff --git a/packages/extension/src/providers/solana/libs/api.ts b/packages/extension/src/providers/solana/libs/api.ts index 9931e7526..2893cbdae 100644 --- a/packages/extension/src/providers/solana/libs/api.ts +++ b/packages/extension/src/providers/solana/libs/api.ts @@ -23,36 +23,48 @@ class API implements ProviderAPIInterface { return getSolAddress(pubkey); } - async init(): Promise {} + async init(): Promise { } + + /** + * Returns null if the transaction hasn't been received by the node + * or has been dropped + */ async getTransactionStatus(hash: string): Promise { - return this.web3 - .getTransaction(hash, { - maxSupportedTransactionVersion: 0, - commitment: 'confirmed', - }) - .then(tx => { - if (!tx) return null; - const retVal: SOLRawInfo = { - blockNumber: tx.slot, - timestamp: tx.blockTime, - transactionHash: hash, - status: tx.meta?.err ? false : true, - }; - return retVal; - }); + const tx = await this.web3.getTransaction(hash, { + maxSupportedTransactionVersion: 0, + commitment: 'confirmed', + }) + + if (!tx) { + // Transaction hasn't been picked up by the node + // (maybe it's too soon, or maybe node is behind, or maybe it's been dropped) + return null; + } + + const retVal: SOLRawInfo = { + blockNumber: tx.slot, + timestamp: tx.blockTime, + transactionHash: hash, + status: tx.meta?.err ? false : true, + }; + + return retVal; } + async getBalance(pubkey: string): Promise { const balance = await this.web3.getBalance( new PublicKey(this.getAddress(pubkey)), ); return numberToHex(balance); } + async broadcastTx(rawtx: string): Promise { return this.web3 .sendRawTransaction(hexToBuffer(rawtx)) .then(() => true) .catch(() => false); } + getTokenInfo = async (contractAddress: string): Promise => { interface TokenDetails { address: string; diff --git a/packages/extension/src/types/activity.ts b/packages/extension/src/types/activity.ts index 89db8af7d..1c7511af8 100644 --- a/packages/extension/src/types/activity.ts +++ b/packages/extension/src/types/activity.ts @@ -95,6 +95,7 @@ enum ActivityStatus { pending = 'pending', success = 'success', failed = 'failed', + dropped = 'dropped', } enum ActivityType { @@ -121,13 +122,13 @@ interface Activity { status: ActivityStatus; type: ActivityType; rawInfo?: - | EthereumRawInfo - | SubstrateRawInfo - | SubscanExtrinsicInfo - | BTCRawInfo - | SwapRawInfo - | KadenaRawInfo - | SOLRawInfo; + | EthereumRawInfo + | SubstrateRawInfo + | SubscanExtrinsicInfo + | BTCRawInfo + | SwapRawInfo + | KadenaRawInfo + | SOLRawInfo; } export { diff --git a/packages/extension/src/ui/action/views/network-activity/components/network-activity-transaction.vue b/packages/extension/src/ui/action/views/network-activity/components/network-activity-transaction.vue index a423f128d..1f81a6f8a 100644 --- a/packages/extension/src/ui/action/views/network-activity/components/network-activity-transaction.vue +++ b/packages/extension/src/ui/action/views/network-activity/components/network-activity-transaction.vue @@ -37,7 +37,10 @@

{{ status }} {{ status }} { if ( props.activity.status === ActivityStatus.success && props.activity.isIncoming - ) + ) { status.value = props.activity.type === ActivityType.transaction ? 'Received' : 'Swapped'; - else if ( + } else if ( props.activity.status === ActivityStatus.success && !props.activity.isIncoming - ) + ) { status.value = props.activity.type === ActivityType.transaction ? 'Sent' : 'Swapped'; - else if ( + } else if ( props.activity.status === ActivityStatus.pending && props.activity.isIncoming - ) + ) { status.value = props.activity.type === ActivityType.transaction ? 'Receiving' : 'Swapping'; - else if ( + } else if ( props.activity.status === ActivityStatus.pending && !props.activity.isIncoming - ) + ) { status.value = props.activity.type === ActivityType.transaction ? 'Sending' : 'Swapping'; - else { + } else if (props.activity.status === ActivityStatus.dropped) { + status.value = 'Dropped'; + } else { status.value = 'Failed'; } }); @@ -335,6 +343,9 @@ onMounted(() => { .error { color: @error; } + .dropped { + /* TODO: Consider different color */ + } } } diff --git a/packages/extension/src/ui/action/views/network-activity/index.vue b/packages/extension/src/ui/action/views/network-activity/index.vue index e15b39ada..5a9b9f243 100644 --- a/packages/extension/src/ui/action/views/network-activity/index.vue +++ b/packages/extension/src/ui/action/views/network-activity/index.vue @@ -116,10 +116,13 @@ apiPromise.then(api => { }); }); -const activityCheckTimers: any[] = []; +/** Intervals that trigger calls to check for updates in transaction activity */ +const activityCheckTimers: ReturnType[] = []; + const activityAddress = computed(() => props.network.displayAddress(props.accountInfo.selectedAccount!.address), ); + const updateVisibleActivity = (activity: Activity): void => { activities.value.forEach((act, idx) => { if (act.transactionHash === activity.transactionHash) { @@ -129,96 +132,135 @@ const updateVisibleActivity = (activity: Activity): void => { forceUpdateVal.value++; }; +/** Set a timer to periodically check (and update) the status of an activity item (transaction) */ const checkActivity = (activity: Activity): void => { activity = toRaw(activity); const timer = setInterval(() => { apiPromise.then(api => { api.getTransactionStatus(activity.transactionHash).then(info => { - getInfo(activity, info, timer); + handleActivityUpdate(activity, info, timer); }); }); - }, 5000); + }, 5_000); + + // Register the interval timer so we can destroy it on component teardown activityCheckTimers.push(timer); }; -const getInfo = (activity: Activity, info: any, timer: any) => { - if (info) { - if (props.network.provider === ProviderName.ethereum) { - const evmInfo = info as EthereumRawInfo; - activity.status = evmInfo.status + +const handleActivityUpdate = (activity: Activity, info: any, timer: any) => { + if (props.network.provider === ProviderName.ethereum) { + if (!info) return; + const evmInfo = info as EthereumRawInfo; + activity.status = evmInfo.status + ? ActivityStatus.success + : ActivityStatus.failed; + activity.rawInfo = evmInfo; + activityState + .updateActivity(activity, { + address: activityAddress.value, + network: props.network.name, + }) + .then(() => updateVisibleActivity(activity)); + } else if (props.network.provider === ProviderName.polkadot) { + if (!info) return; + const subInfo = info as SubscanExtrinsicInfo; + if (!subInfo.pending) { + activity.status = subInfo.success ? ActivityStatus.success : ActivityStatus.failed; - activity.rawInfo = evmInfo; + activity.rawInfo = subInfo; activityState .updateActivity(activity, { address: activityAddress.value, network: props.network.name, }) .then(() => updateVisibleActivity(activity)); - } else if (props.network.provider === ProviderName.polkadot) { - const subInfo = info as SubscanExtrinsicInfo; - if (!subInfo.pending) { - activity.status = subInfo.success - ? ActivityStatus.success - : ActivityStatus.failed; - activity.rawInfo = subInfo; - activityState - .updateActivity(activity, { - address: activityAddress.value, - network: props.network.name, - }) - .then(() => updateVisibleActivity(activity)); - } - } else if (props.network.provider === ProviderName.bitcoin) { - const btcInfo = info as BTCRawInfo; - activity.status = ActivityStatus.success; - activity.rawInfo = btcInfo; - activityState - .updateActivity(activity, { - address: activityAddress.value, - network: props.network.name, - }) - .then(() => updateVisibleActivity(activity)); - } else if (props.network.provider === ProviderName.kadena) { - const kadenaInfo = info as KadenaRawInfo; - activity.status = - kadenaInfo.result.status == 'success' - ? ActivityStatus.success - : ActivityStatus.failed; - activity.rawInfo = kadenaInfo as KadenaRawInfo; + } + } else if (props.network.provider === ProviderName.bitcoin) { + if (!info) return; + const btcInfo = info as BTCRawInfo; + activity.status = ActivityStatus.success; + activity.rawInfo = btcInfo; + activityState + .updateActivity(activity, { + address: activityAddress.value, + network: props.network.name, + }) + .then(() => updateVisibleActivity(activity)); + } else if (props.network.provider === ProviderName.kadena) { + if (!info) return; + const kadenaInfo = info as KadenaRawInfo; + activity.status = + kadenaInfo.result.status == 'success' + ? ActivityStatus.success + : ActivityStatus.failed; + activity.rawInfo = kadenaInfo as KadenaRawInfo; + activityState + .updateActivity(activity, { + address: activityAddress.value, + network: props.network.name, + }) + .then(() => updateVisibleActivity(activity)); + } else if (props.network.provider === ProviderName.solana) { + if (info) { + console.log('[[ ??? doing something ??? ]]', info); + const solInfo = info as SOLRawInfo; + activity.status = info.status + ? ActivityStatus.success + : ActivityStatus.failed; + activity.rawInfo = solInfo; activityState .updateActivity(activity, { address: activityAddress.value, network: props.network.name, }) .then(() => updateVisibleActivity(activity)); - } else if (props.network.provider === ProviderName.solana) { - const solInfo = info as SOLRawInfo; - activity.status = info.status - ? ActivityStatus.success - : ActivityStatus.failed; - activity.rawInfo = solInfo; + } else if (Date.now() > activity.timestamp + 3 * 60_000) { + // Either our node is behind or the transaction was dropped + // Consider the transaction expired + activity.status = ActivityStatus.dropped; activityState .updateActivity(activity, { address: activityAddress.value, network: props.network.name, }) .then(() => updateVisibleActivity(activity)); + } else { + return; /* Give the transaction more time to be mined */ } - clearInterval(timer); } + + // If we're this far in then the transaction has reached a terminal status + // No longer need to check this activity + clearInterval(timer); }; + const checkSwap = (activity: Activity): void => { activity = toRaw(activity); const timer = setInterval(() => { if (swap) { swap.initPromise.then(() => { swap.getStatus((activity.rawInfo as SwapRawInfo).status).then(info => { - if (info === TransactionStatus.pending) return; - activity.status = - info === TransactionStatus.success - ? ActivityStatus.success - : ActivityStatus.failed; + switch (info) { + case TransactionStatus.pending: + // noop + return; + case TransactionStatus.success: + activity.status = ActivityStatus.success; + break; + case TransactionStatus.failed: + case null: + activity.status = ActivityStatus.failed; + break; + case TransactionStatus.dropped: + activity.status = ActivityStatus.dropped; + break; + default: + info satisfies never; + console.error('Unknown swap status:', info); + return; + } activityState .updateActivity(activity, { address: activityAddress.value, @@ -241,15 +283,19 @@ const setActivities = () => { isNoActivity.value = all.length === 0; activities.value.forEach(act => { if ( - act.status === ActivityStatus.pending && + (act.status === ActivityStatus.pending || + act.status === ActivityStatus.dropped) && act.type === ActivityType.transaction - ) + ) { checkActivity(act); + } if ( - act.status === ActivityStatus.pending && + (act.status === ActivityStatus.pending || + act.status === ActivityStatus.dropped) && act.type === ActivityType.swap - ) + ) { checkSwap(act); + } }); }); else activities.value = []; @@ -259,9 +305,11 @@ watch([selectedAddress, selectedNetworkName], setActivities); onMounted(() => { setActivities(); activityCheckTimers.forEach(timer => clearInterval(timer)); + activityCheckTimers.length = 0; }); onUnmounted(() => { activityCheckTimers.forEach(timer => clearInterval(timer)); + activityCheckTimers.length = 0; }); diff --git a/packages/extension/src/ui/action/views/swap/index.vue b/packages/extension/src/ui/action/views/swap/index.vue index a3caa71c0..ea0b71530 100644 --- a/packages/extension/src/ui/action/views/swap/index.vue +++ b/packages/extension/src/ui/action/views/swap/index.vue @@ -840,7 +840,7 @@ const sendAction = async () => { const tradeStatusOptions = trades.map(t => t!.getStatusObject({ - transactionHashes: [], + transactions: [], }), ); diff --git a/packages/extension/src/ui/action/views/swap/libs/send-transactions.ts b/packages/extension/src/ui/action/views/swap/libs/send-transactions.ts index d5cef90db..192357bb7 100644 --- a/packages/extension/src/ui/action/views/swap/libs/send-transactions.ts +++ b/packages/extension/src/ui/action/views/swap/libs/send-transactions.ts @@ -75,7 +75,7 @@ const getBaseActivity = (options: ExecuteSwapOptions): Activity => { */ export const executeSwap = async ( options: ExecuteSwapOptions, -): Promise => { +): Promise<{ hash: string, sentAt: number }[]> => { const activityState = new ActivityState(); const api = await options.network.api(); if (options.networkType === NetworkType.Bitcoin) { @@ -116,7 +116,7 @@ export const executeSwap = async ( network: options.network.name, }); }); - return [signedTx.getId() as `0x${string}`]; + return [{ hash: signedTx.getId() as `0x${string}`, sentAt: Date.now() }]; } else if (options.networkType === NetworkType.Substrate) { const substrateTx = await getSubstrateNativeTransation( options.network as SubstrateNetwork, @@ -173,12 +173,12 @@ export const executeSwap = async ( { address: txActivity.from, network: options.network.name }, ); }); - return [hash]; + return [{ hash, sentAt: Date.now(), }]; } else if (options.networkType === NetworkType.Solana) { // Execute the swap on Solana const conn = (api as SolanaAPI).api.web3; - const solTxHashes: string[] = []; + const solTxs: { hash: string, sentAt: number, }[] = []; /** Enkrypt representation of the swap transactions */ const enkSolTxs = options.swap.transactions as EnkryptSolanaTransaction[]; @@ -200,7 +200,7 @@ export const executeSwap = async ( const hasThirdPartySignatures = // Serialized versioned transaction was already signed legacyTx.signatures.length > 1 || - // Need to apply third aprty signatures to the transaction + // Need to apply third party signatures to the transaction thirdPartySignatures.length > 0; if (hasThirdPartySignatures) { @@ -219,7 +219,7 @@ export const executeSwap = async ( // Might need to update the block hash console.warn( `Failed to get fee for legacy transaction while checking` + - ` whether to update block hash: ${String(err)}`, + ` whether to update block hash: ${String(err)}`, ); shouldUpdateBlockHash = true; } @@ -228,7 +228,7 @@ export const executeSwap = async ( if (shouldUpdateBlockHash) { console.warn( `Unsigned legacy transaction might have an` + - ` out-of-date block hash, trying to update it...`, + ` out-of-date block hash, trying to update it...`, ); const backoff = [0, 500, 1_000, 2_000]; let backoffi = 0; @@ -238,7 +238,7 @@ export const executeSwap = async ( // Just continue and hope for the best with old block hash... console.warn( `Failed to get latest blockhash after ${backoffi} attempts,` + - ` continuing with old block hash for legacy transaction...`, + ` continuing with old block hash for legacy transaction...`, ); break update_block_hash; } @@ -246,7 +246,7 @@ export const executeSwap = async ( if (backoffMs > 0) { console.warn( `Waiting ${backoffMs}ms before retrying latest block` + - ` hash for legacy transaction...`, + ` hash for legacy transaction...`, ); await new Promise(res => setTimeout(res, backoffMs)); } @@ -257,7 +257,7 @@ export const executeSwap = async ( } catch (err) { console.warn( `Failed to get latest blockhash on attempt` + - ` ${backoffi + 1}: ${String(err)}`, + ` ${backoffi + 1}: ${String(err)}`, ); } backoffi++; @@ -330,7 +330,7 @@ export const executeSwap = async ( // Might need to update the block hash console.warn( `Failed to get fee for versioned transaction while checking` + - ` whether to update block hash: ${String(err)}`, + ` whether to update block hash: ${String(err)}`, ); shouldUpdateBlockHash = true; } @@ -339,7 +339,7 @@ export const executeSwap = async ( if (shouldUpdateBlockHash) { console.warn( `Unsigned versioned transaction might have an` + - ` out-of-date block hash, trying to update it...`, + ` out-of-date block hash, trying to update it...`, ); const backoff = [0, 500, 1_000, 2_000]; let backoffi = 0; @@ -349,7 +349,7 @@ export const executeSwap = async ( // Just continue and hope for the best with old block hash... console.warn( `Failed to get latest blockhash after ${backoffi} attempts,` + - ` continuing with old block hash for versioned transaction...`, + ` continuing with old block hash for versioned transaction...`, ); break update_block_hash; } @@ -357,7 +357,7 @@ export const executeSwap = async ( if (backoffMs > 0) { console.warn( `Waiting ${backoffMs}ms before retrying latest block` + - ` hash for versioned transaction...`, + ` hash for versioned transaction...`, ); await new Promise(res => setTimeout(res, backoffMs)); } @@ -368,7 +368,7 @@ export const executeSwap = async ( } catch (err) { console.warn( `Failed to get latest blockhash on attempt` + - ` ${backoffi + 1}: ${String(err)}`, + ` ${backoffi + 1}: ${String(err)}`, ); } backoffi++; @@ -434,8 +434,8 @@ export const executeSwap = async ( ); throw new Error( 'Failed to send Solana swap transaction: blockhash not found.' + - ' Too much time may have passed between the creation and sending' + - ' of the transaction', + ' Too much time may have passed between the creation and sending' + + ' of the transaction', ); } @@ -454,7 +454,7 @@ export const executeSwap = async ( } else { console.error( `Failed to send Solana swap transaction,` + - ` unhandled error ${(err as Error).name}`, + ` unhandled error ${(err as Error).name}`, ); } // Solana transactions can have big errors @@ -475,12 +475,12 @@ export const executeSwap = async ( network: options.network.name, }); - solTxHashes.push(txHash); + solTxs.push({ hash: txHash, sentAt: Date.now(), }); } // Finished executing the swap on Solana - return solTxHashes; + return solTxs; } else if (options.networkType === NetworkType.EVM) { const web3 = (api as EvmAPI).web3; const nonce = await web3.getTransactionCount(options.from.address); @@ -504,7 +504,7 @@ export const executeSwap = async ( ); const txs = await Promise.all(txsPromises); /** Hashes of transactions successfully sent & mined, in order of execution */ - const txPromises: `0x${string}`[] = []; + const txPromises: { hash: `0x${string}`, sentAt: number, }[] = []; for (const txInfo of txs) { // Submit each transaction, in-order one-by-one @@ -566,7 +566,7 @@ export const executeSwap = async ( ); }), ); - txPromises.push(hash); + txPromises.push({ hash, sentAt: Date.now(), }); } return txPromises; } else { diff --git a/packages/extension/src/ui/action/views/swap/views/swap-best-offer/index.vue b/packages/extension/src/ui/action/views/swap/views/swap-best-offer/index.vue index c9eb6d862..8f1e8e271 100644 --- a/packages/extension/src/ui/action/views/swap/views/swap-best-offer/index.vue +++ b/packages/extension/src/ui/action/views/swap/views/swap-best-offer/index.vue @@ -391,8 +391,8 @@ const sendAction = async () => { swap: pickedTrade.value, toToken: swapData.toToken, }) - .then(hashes => { - pickedTrade.value.status!.options.transactionHashes = hashes; + .then(txs => { + pickedTrade.value.status!.options.transactions = txs; const swapRaw: SwapRawInfo = { fromToken: swapData.fromToken, toToken: swapData.toToken, @@ -417,7 +417,7 @@ const sendAction = async () => { timestamp: new Date().getTime(), type: ActivityType.swap, value: pickedTrade.value.toTokenAmount.toString(), - transactionHash: `${hashes[0]}-swap`, + transactionHash: `${txs[0].hash}-swap`, rawInfo: JSON.parse(JSON.stringify(swapRaw)), }; const activityState = new ActivityState(); diff --git a/packages/swap/src/providers/changelly/index.ts b/packages/swap/src/providers/changelly/index.ts index 45826e217..50cd460d7 100644 --- a/packages/swap/src/providers/changelly/index.ts +++ b/packages/swap/src/providers/changelly/index.ts @@ -1022,6 +1022,9 @@ class Changelly extends ProviderClass { } async getStatus(options: StatusOptions): Promise { + // TODO: If a Solana transaction hasn't been found after 3 minutes then consider dropping it + // I'm not sure how Rango's API handles Solana transactions being dropped... + const params: ChangellyApiGetStatusParams = { id: options.swapId, }; diff --git a/packages/swap/src/providers/jupiter/index.ts b/packages/swap/src/providers/jupiter/index.ts index f6fa30630..696b70c83 100644 --- a/packages/swap/src/providers/jupiter/index.ts +++ b/packages/swap/src/providers/jupiter/index.ts @@ -536,17 +536,23 @@ export class Jupiter extends ProviderClass { } async getStatus(options: StatusOptions): Promise { - if (options.transactionHashes.length !== 1) { + if (options.transactions.length !== 1) { throw new TypeError( - `JupiterSwap.getStatus: Expected one transaction hash but got ${options.transactionHashes.length}`, + `JupiterSwap.getStatus: Expected one transaction hash but got ${options.transactions.length}`, ); } - const [txhash] = options.transactionHashes; - const txResponse = await this.conn.getTransaction(txhash, { + const [{ sentAt, hash }] = options.transactions; + const txResponse = await this.conn.getTransaction(hash, { maxSupportedTransactionVersion: 0, }); if (txResponse == null) { + // Consider dropped (/failed) if it's still null after 3 minutes + // (block hashes expire after 2 minutes so 3 minutes gives 1 minute of leeway) + if (Date.now() > sentAt + 3 * 60_000) { + return TransactionStatus.dropped; + } + // Transaction hasn't been picked up by the node yet return TransactionStatus.pending; } diff --git a/packages/swap/src/providers/oneInch/index.ts b/packages/swap/src/providers/oneInch/index.ts index d5b931219..af3e83a79 100644 --- a/packages/swap/src/providers/oneInch/index.ts +++ b/packages/swap/src/providers/oneInch/index.ts @@ -295,7 +295,7 @@ class OneInch extends ProviderClass { } getStatus(options: StatusOptions): Promise { - const promises = options.transactionHashes.map((hash) => + const promises = options.transactions.map(({ hash }) => this.web3eth.getTransactionReceipt(hash), ); return Promise.all(promises).then((receipts) => { diff --git a/packages/swap/src/providers/paraswap/index.ts b/packages/swap/src/providers/paraswap/index.ts index 956b2cc19..a3ee7f010 100644 --- a/packages/swap/src/providers/paraswap/index.ts +++ b/packages/swap/src/providers/paraswap/index.ts @@ -350,7 +350,7 @@ class ParaSwap extends ProviderClass { } getStatus(options: StatusOptions): Promise { - const promises = options.transactionHashes.map((hash) => + const promises = options.transactions.map(({ hash }) => this.web3eth.getTransactionReceipt(hash), ); return Promise.all(promises).then((receipts) => { diff --git a/packages/swap/src/providers/rango/index.ts b/packages/swap/src/providers/rango/index.ts index 39c0e9080..aaa4f5651 100644 --- a/packages/swap/src/providers/rango/index.ts +++ b/packages/swap/src/providers/rango/index.ts @@ -1192,30 +1192,34 @@ class Rango extends ProviderClass { } async getStatus(options: StatusOptions): Promise { - const { requestId, transactionHashes } = options; + const { requestId, transactions } = options; - const transactionHash = - transactionHashes.length > 0 - ? transactionHashes[transactionHashes.length - 1] - : transactionHashes[0]; + // TODO: If a Solana transaction hasn't been found after 3 minutes then consider dropping it + // I'm not sure how Rango's API handles Solana transactions being dropped... + + const mostRecentTransactionHash = + transactions.length > 0 + ? transactions[transactions.length - 1].hash + : transactions[0].hash; const isAlreadySuccessOrFailed = [ RangoTransactionStatus.FAILED, RangoTransactionStatus.SUCCESS, ].includes( - this.transactionsStatus.find((t) => t.hash === transactionHash)?.status, + this.transactionsStatus.find((t) => t.hash === mostRecentTransactionHash) + ?.status, ); if (requestId && !isAlreadySuccessOrFailed) { const res = await rangoClient.status({ - txId: transactionHash, + txId: mostRecentTransactionHash, requestId, }); if (res.error || res.status === RangoTransactionStatus.FAILED) { this.transactionsStatus.push({ status: RangoTransactionStatus.FAILED, - hash: transactionHash, + hash: mostRecentTransactionHash, }); return TransactionStatus.failed; } @@ -1224,7 +1228,7 @@ class Rango extends ProviderClass { } this.transactionsStatus.push({ status: RangoTransactionStatus.SUCCESS, - hash: transactionHash, + hash: mostRecentTransactionHash, }); return TransactionStatus.success; } @@ -1235,7 +1239,7 @@ class Rango extends ProviderClass { // Get status of Solana transactions const sigStatuses = await ( this.web3 as Connection - ).getSignatureStatuses(transactionHashes); + ).getSignatureStatuses(transactions.map(({ hash }) => hash)); for (let i = 0, len = sigStatuses.value.length; i < len; i++) { const sigStatus = sigStatuses.value[i]; if (sigStatus == null) { @@ -1252,7 +1256,7 @@ class Rango extends ProviderClass { default: { // Get status of EVM transactions const receipts = await Promise.all( - transactionHashes.map((hash) => + transactions.map(({ hash }) => (this.web3 as Web3Eth).getTransactionReceipt(hash), ), ); diff --git a/packages/swap/src/providers/zerox/index.ts b/packages/swap/src/providers/zerox/index.ts index 552243e8a..bc2edfeac 100644 --- a/packages/swap/src/providers/zerox/index.ts +++ b/packages/swap/src/providers/zerox/index.ts @@ -272,7 +272,7 @@ class ZeroX extends ProviderClass { } getStatus(options: StatusOptions): Promise { - const promises = options.transactionHashes.map((hash) => + const promises = options.transactions.map(({ hash }) => this.web3eth.getTransactionReceipt(hash), ); return Promise.all(promises).then((receipts) => { diff --git a/packages/swap/src/types/index.ts b/packages/swap/src/types/index.ts index 48dc702a7..6d9443dd3 100644 --- a/packages/swap/src/types/index.ts +++ b/packages/swap/src/types/index.ts @@ -135,6 +135,7 @@ export enum TransactionStatus { pending = "pending", failed = "failed", success = "success", + dropped = "dropped", } export interface getQuoteOptions { @@ -220,9 +221,17 @@ export interface ProviderQuoteResponse { quote: SwapQuote; minMax: MinMaxResponse; } + +export type StatusOptionTransaction = { + /** Transaction hash */ + hash: string; + /** Unix epoch milliseconds `Date.now()` */ + sentAt: number; +}; + export interface StatusOptions { [key: string]: any; - transactionHashes: string[]; + transactions: StatusOptionTransaction[]; } export interface StatusOptionsResponse { diff --git a/packages/swap/tests/changelly.test.ts b/packages/swap/tests/changelly.test.ts index 474325017..3f9708c42 100644 --- a/packages/swap/tests/changelly.test.ts +++ b/packages/swap/tests/changelly.test.ts @@ -51,7 +51,9 @@ describe("Changelly Provider", () => { (swap?.transactions[0] as EVMTransaction).data.startsWith("0xa9059cbb"), ).to.be.eq(true); const status = await changelly.getStatus( - (await swap!.getStatusObject({ transactionHashes: [] })).options, + ( + await swap!.getStatusObject({ transactions: [] }) + ).options, ); expect(status).to.be.eq("pending"); });