From fde689f88fb6246d3c23bd85b08e6295311fde8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren?= Date: Fri, 1 Nov 2024 15:17:17 +0100 Subject: [PATCH] Implement USDT swapping --- package.json | 6 +- src/components/BtcTransactionListItem.vue | 2 +- src/components/TransactionListItem.vue | 3 +- src/components/layouts/AccountOverview.vue | 6 +- src/components/layouts/Sidebar.vue | 6 +- src/components/modals/BtcTransactionModal.vue | 36 +- src/components/modals/TransactionModal.vue | 40 +- .../modals/UsdtTransactionModal.vue | 194 +++--- src/components/swap/SwapAnimation.vue | 26 +- src/components/swap/SwapBalanceBar.vue | 41 +- src/components/swap/SwapModal.vue | 555 ++++++++++++++---- src/components/swap/SwapNotification.vue | 176 +++++- src/components/swap/animation/usdt.svg | 3 + src/composables/useBtcTransactionInfo.ts | 4 + src/composables/useSwapLimits.ts | 80 ++- src/composables/useTransactionInfo.ts | 4 + src/composables/useUsdcTransactionInfo.ts | 4 +- src/composables/useUsdtTransactionInfo.ts | 34 +- src/ethers.ts | 2 + src/lib/ExplorerUtils.ts | 2 +- src/lib/swap/utils/Assets.ts | 4 +- src/lib/swap/utils/Functions.ts | 4 +- src/stores/Swaps.ts | 2 +- src/stores/UsdtTransactions.ts | 120 ++-- yarn.lock | 35 +- 25 files changed, 1009 insertions(+), 380 deletions(-) create mode 100644 src/components/swap/animation/usdt.svg diff --git a/package.json b/package.json index 7e6345961..8c7d30b64 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,10 @@ "@formatjs/intl-displaynames": "^3.3.4", "@linusborg/vue-simple-portal": "^0.1.4", "@nimiq/electrum-client": "https://github.com/nimiq/electrum-client#build", - "@nimiq/fastspot-api": "^1.10.0", - "@nimiq/hub-api": "^1.8.0", + "@nimiq/fastspot-api": "^1.10.2", + "@nimiq/hub-api": "https://gitpkg.now.sh/nimiq/hub/client?73c31757aa9e93912f7540a272914ecdb51a23ad", "@nimiq/iqons": "^1.5.2", - "@nimiq/libswap": "^1.4.0", + "@nimiq/libswap": "^1.4.1", "@nimiq/oasis-api": "^1.1.1", "@nimiq/oasis-bank-list": "https://github.com/nimiq/oasis-bank-list#main", "@nimiq/rpc": "^0.4.1", diff --git a/src/components/BtcTransactionListItem.vue b/src/components/BtcTransactionListItem.vue index a824a0433..991cbf81d 100644 --- a/src/components/BtcTransactionListItem.vue +++ b/src/components/BtcTransactionListItem.vue @@ -29,7 +29,7 @@ + && (swapData.asset === SwapAsset.USDT_MATIC)"/> diff --git a/src/components/TransactionListItem.vue b/src/components/TransactionListItem.vue index 182cc319d..a31e3fefb 100644 --- a/src/components/TransactionListItem.vue +++ b/src/components/TransactionListItem.vue @@ -26,8 +26,7 @@ - + diff --git a/src/components/layouts/AccountOverview.vue b/src/components/layouts/AccountOverview.vue index 9523360a6..2c0f5e28d 100644 --- a/src/components/layouts/AccountOverview.vue +++ b/src/components/layouts/AccountOverview.vue @@ -84,7 +84,6 @@ v-if="$config.fastspot.enabled && activeAccountInfo.type !== AccountType.LEDGER && hasPolygonAddresses && $config.polygon.enabled - && stablecoin === CryptoCurrency.USDC && ( nimAccountBalance > 0 || (stablecoin === CryptoCurrency.USDC @@ -104,7 +103,7 @@ $event, `${SwapAsset.NIM}-${stablecoin === CryptoCurrency.USDC ? SwapAsset.USDC_MATIC - : SwapAsset.USDT}` + : SwapAsset.USDT_MATIC}` )" @focus="nimUsdcSwapTooltip$ && nimUsdcSwapTooltip$.show()" @blur="nimUsdcSwapTooltip$ && nimUsdcSwapTooltip$.hide()" @@ -156,7 +155,6 @@ && activeAccountInfo.type !== AccountType.LEDGER && hasBitcoinAddresses && $config.enableBitcoin && hasPolygonAddresses && $config.polygon.enabled - && stablecoin === CryptoCurrency.USDC && ( btcAccountBalance > 0 || (stablecoin === CryptoCurrency.USDC @@ -176,7 +174,7 @@ $event, `${SwapAsset.BTC}-${stablecoin === CryptoCurrency.USDC ? SwapAsset.USDC_MATIC - : SwapAsset.USDT}` + : SwapAsset.USDT_MATIC}` )" @focus="btcUsdcSwapTooltip$ && btcUsdcSwapTooltip$.show()" @blur="btcUsdcSwapTooltip$ && btcUsdcSwapTooltip$.hide()" diff --git a/src/components/layouts/Sidebar.vue b/src/components/layouts/Sidebar.vue index 70829bd2f..12116c441 100644 --- a/src/components/layouts/Sidebar.vue +++ b/src/components/layouts/Sidebar.vue @@ -291,9 +291,9 @@ export default defineComponent({ ? stablecoin.value === CryptoCurrency.USDC // For USDC, only native USDC is supported for swapping. ? store.accountUsdcBalance.value - // : stablecoin.value === CryptoCurrency.USDT - // ? store.accountUsdtBridgedBalance.value - : 0 + : stablecoin.value === CryptoCurrency.USDT + ? store.accountUsdtBridgedBalance.value + : 0 : store.accountBalance.value; }), ); diff --git a/src/components/modals/BtcTransactionModal.vue b/src/components/modals/BtcTransactionModal.vue index 1c92c3ee5..3a206fd7d 100644 --- a/src/components/modals/BtcTransactionModal.vue +++ b/src/components/modals/BtcTransactionModal.vue @@ -12,7 +12,8 @@ @@ -26,7 +27,8 @@ @@ -92,8 +94,7 @@ :address="peerAddresses[0]"/> - + @@ -147,8 +148,7 @@ :address="peerAddresses[0]"/> - + @@ -265,6 +265,20 @@ class="swapped-amount" value-mask/> +
@@ -357,6 +371,7 @@ import { explorerTxLink } from '../../lib/ExplorerUtils'; import { assetToCurrency } from '../../lib/swap/utils/Assets'; import TransactionDetailOasisPayoutStatus from '../TransactionDetailOasisPayoutStatus.vue'; import { useUsdcTransactionsStore, Transaction as UsdcTransaction } from '../../stores/UsdcTransactions'; +import { useUsdtTransactionsStore, Transaction as UsdtTransaction } from '../../stores/UsdtTransactions'; import { useBtcTransactionInfo } from '../../composables/useBtcTransactionInfo'; import UsdcIcon from '../icons/UsdcIcon.vue'; import UsdtIcon from '../icons/UsdtIcon.vue'; @@ -421,6 +436,10 @@ export default defineComponent({ return useUsdcTransactionsStore().state.transactions[swapData.value.transactionHash] || null; } + if (swapData.value.asset === SwapAsset.USDT_MATIC) { + return useUsdtTransactionsStore().state.transactions[swapData.value.transactionHash] || null; + } + return null; }); @@ -468,6 +487,11 @@ export default defineComponent({ return isIncoming.value ? [swapTx.sender] : [swapTx.recipient]; } + if (swapData.value.asset === SwapAsset.USDT_MATIC && swapTransaction.value) { + const swapTx = swapTransaction.value as UsdtTransaction; + return isIncoming.value ? [swapTx.sender] : [swapTx.recipient]; + } + if (swapData.value.asset === SwapAsset.EUR) return [swapData.value.iban || '']; } diff --git a/src/components/modals/TransactionModal.vue b/src/components/modals/TransactionModal.vue index bbeac25d3..5d84137e9 100644 --- a/src/components/modals/TransactionModal.vue +++ b/src/components/modals/TransactionModal.vue @@ -9,7 +9,8 @@ @@ -23,7 +24,8 @@ @@ -106,8 +108,7 @@ - + @@ -150,8 +151,7 @@ - + @@ -269,6 +269,20 @@ class="swapped-amount" value-mask/> +
@@ -363,6 +377,7 @@ import { manageCashlink, refundSwap } from '../../hub'; import { SwapNimData } from '../../stores/Swaps'; import { useBtcTransactionsStore, Transaction as BtcTransaction } from '../../stores/BtcTransactions'; import { useUsdcTransactionsStore, Transaction as UsdcTransaction } from '../../stores/UsdcTransactions'; +import { useUsdtTransactionsStore, Transaction as UsdtTransaction } from '../../stores/UsdtTransactions'; import { sendTransaction } from '../../network'; import { useAccountStore, AccountType } from '../../stores/Account'; import { explorerTxLink } from '../../lib/ExplorerUtils'; @@ -476,6 +491,12 @@ export default defineComponent({ return usdcTx; } + if (swapData.value.asset === SwapAsset.USDT_MATIC) { + const usdtTx = useUsdtTransactionsStore().state.transactions[swapData.value.transactionHash]; + if (!usdtTx) return null; + return usdtTx; + } + return null; }); @@ -532,6 +553,13 @@ export default defineComponent({ : ''; // we don't know the peer address } + if (swapData.value.asset === SwapAsset.USDT_MATIC) { + const swapTx = swapTransaction.value as UsdtTransaction | null; + return swapTx + ? isIncoming.value ? swapTx.sender : swapTx.recipient + : ''; // we don't know the peer address + } + if (swapData.value.asset === SwapAsset.EUR) { return swapData.value.iban || ''; } diff --git a/src/components/modals/UsdtTransactionModal.vue b/src/components/modals/UsdtTransactionModal.vue index 5fafcadc7..b3c8d1a92 100644 --- a/src/components/modals/UsdtTransactionModal.vue +++ b/src/components/modals/UsdtTransactionModal.vue @@ -293,7 +293,7 @@ import GroundedArrowDownIcon from '../icons/GroundedArrowDownIcon.vue'; import Avatar from '../Avatar.vue'; import InteractiveShortAddress from '../InteractiveShortAddress.vue'; import TransactionDetailOasisPayoutStatus from '../TransactionDetailOasisPayoutStatus.vue'; -// import { SwapUsdtData } from '../../stores/Swaps'; +import { SwapErc20Data } from '../../stores/Swaps'; import { useTransactionsStore, Transaction as NimTransaction } from '../../stores/Transactions'; import { useBtcTransactionsStore, Transaction as BtcTransaction } from '../../stores/BtcTransactions'; import { isProxyData, ProxyType } from '../../lib/ProxyDetection'; @@ -431,107 +431,107 @@ export default defineComponent({ const blockExplorerLink = computed(() => explorerTxLink(CryptoCurrency.USDT, transaction.value.transactionHash)); - const showRefundButton = computed(() => false && !isIncoming.value, - // // funded but not redeemed htlc which is now expired - // && (swapInfo.value?.in?.asset === SwapAsset.USDT) - // && (swapInfo.value.in.htlc?.timeoutTimestamp || Number.POSITIVE_INFINITY) <= Date.now() / 1e3 - // && !swapInfo.value.out, + const showRefundButton = computed(() => !isIncoming.value + // funded but not redeemed htlc which is now expired + && (swapInfo.value?.in?.asset === SwapAsset.USDT_MATIC) + && (swapInfo.value.in.htlc?.timeoutTimestamp || Number.POSITIVE_INFINITY) <= Date.now() / 1e3 + && !swapInfo.value.out, // // Only display the refund button for Ledger accounts as the Keyguard signs automatic refund transaction. // && useAccountStore().activeAccountInfo.value?.type === AccountType.LEDGER, ); async function refundHtlc() { - // const htlcDetails = (swapInfo.value?.in as SwapUsdtData | undefined)?.htlc; - // if (!htlcDetails) { - // alert('Unexpected: unknown HTLC refund details'); // eslint-disable-line no-alert - // return; - // } - - // let relayUrl: string; - - // // eslint-disable-next-line no-async-promise-executor - // const requestPromise = new Promise>(async (resolve, reject) => { - // try { - // const myAddress = transaction.value.sender; - - // const method = 'refund'; - - // const htlcContract = await getUsdtBridgedHtlcContract(); - - // const [ - // forwarderNonce, - // { fee, gasPrice, gasLimit, relay }, - // ] = await Promise.all([ - // htlcContract.getNonce(myAddress) as Promise, - // calculateFee( - // transaction.value.token || config.polygon.usdt_bridged.tokenContract, - // method, - // undefined, - // htlcContract, - // ), - // ]); - - // relayUrl = relay.url; - - // const functionData = htlcContract.interface.encodeFunctionData(method, [ - // /** bytes32 id */ htlcDetails.address, - // /** address target */ myAddress, - // /** uint256 fee */ fee, - // ]); - - // const relayRequest: RelayRequest = { - // request: { - // from: myAddress, - // to: htlcContract.address, - // data: functionData, - // value: '0', - // nonce: forwarderNonce.toString(), - // gas: gasLimit.toString(), - // validUntil: (await getPolygonBlockNumber() + 2 * 60 * POLYGON_BLOCKS_PER_MINUTE) - // .toString(10), - // }, - // relayData: { - // gasPrice: gasPrice.toString(), - // pctRelayFee: relay.pctRelayFee.toString(), - // baseRelayFee: relay.baseRelayFee.toString(), - // relayWorker: relay.relayWorkerAddress, - // paymaster: htlcContract.address, - // paymasterData: '0x', - // clientId: Math.floor(Math.random() * 1e6).toString(10), - // forwarder: htlcContract.address, - // }, - // }; - - // const request: Omit = { - // accountId: useAccountStore().activeAccountId.value!, - // refund: { - // type: SwapAsset.USDT, - // ...relayRequest, - // amount: transaction.value.value - fee.toNumber(), - // }, - // }; - - // resolve(request); - // } catch (e) { - // reject(e); - // } - // }); - - // try { - // const tx = await refundSwap(requestPromise); - // if (!tx) return; - // const { relayData, ...relayRequest } = (tx as SignedPolygonTransaction).message; - // const plainTx = await sendTransaction( - // { request: relayRequest as ForwardRequest, relayData }, - // (tx as SignedPolygonTransaction).signature, - // relayUrl!, - // ); - // await context.root.$nextTick(); - // context.root.$router.replace(`/transaction/${plainTx.transactionHash}`); - // } catch (e) { - // const errorMessage = e instanceof Error ? e.message : String(e); - // alert(context.root.$t('Refund failed: ') + errorMessage); // eslint-disable-line no-alert - // } + const htlcDetails = (swapInfo.value?.in as SwapErc20Data | undefined)?.htlc; + if (!htlcDetails) { + alert('Unexpected: unknown HTLC refund details'); // eslint-disable-line no-alert + return; + } + + let relayUrl: string; + + // eslint-disable-next-line no-async-promise-executor + const requestPromise = new Promise>(async (resolve, reject) => { + try { + const myAddress = transaction.value.sender; + + const method = 'refund'; + + const htlcContract = await getUsdtBridgedHtlcContract(); + + const [ + forwarderNonce, + { fee, gasPrice, gasLimit, relay }, + ] = await Promise.all([ + htlcContract.getNonce(myAddress) as Promise, + calculateFee( + transaction.value.token || config.polygon.usdt_bridged.tokenContract, + method, + undefined, + htlcContract, + ), + ]); + + relayUrl = relay.url; + + const functionData = htlcContract.interface.encodeFunctionData(method, [ + /** bytes32 id */ htlcDetails.address, + /** address target */ myAddress, + /** uint256 fee */ fee, + ]); + + const relayRequest: RelayRequest = { + request: { + from: myAddress, + to: htlcContract.address, + data: functionData, + value: '0', + nonce: forwarderNonce.toString(), + gas: gasLimit.toString(), + validUntil: (await getPolygonBlockNumber() + 2 * 60 * POLYGON_BLOCKS_PER_MINUTE) + .toString(10), + }, + relayData: { + gasPrice: gasPrice.toString(), + pctRelayFee: relay.pctRelayFee.toString(), + baseRelayFee: relay.baseRelayFee.toString(), + relayWorker: relay.relayWorkerAddress, + paymaster: htlcContract.address, + paymasterData: '0x', + clientId: Math.floor(Math.random() * 1e6).toString(10), + forwarder: htlcContract.address, + }, + }; + + const request: Omit = { + accountId: useAccountStore().activeAccountId.value!, + refund: { + type: SwapAsset.USDT_MATIC, + ...relayRequest, + amount: transaction.value.value - fee.toNumber(), + }, + }; + + resolve(request); + } catch (e) { + reject(e); + } + }); + + try { + const tx = await refundSwap(requestPromise); + if (!tx) return; + const { relayData, ...relayRequest } = (tx as SignedPolygonTransaction).message; + const plainTx = await sendTransaction( + { request: relayRequest as ForwardRequest, relayData }, + (tx as SignedPolygonTransaction).signature, + relayUrl!, + ); + await context.root.$nextTick(); + context.root.$router.replace(`/transaction/${plainTx.transactionHash}`); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + alert(context.root.$t('Refund failed: ') + errorMessage); // eslint-disable-line no-alert + } } const ticker = CryptoCurrency.USDT; diff --git a/src/components/swap/SwapAnimation.vue b/src/components/swap/SwapAnimation.vue index 6419b1c3e..6cb74cbb9 100644 --- a/src/components/swap/SwapAnimation.vue +++ b/src/components/swap/SwapAnimation.vue @@ -177,7 +177,7 @@ @@ -235,7 +235,7 @@ @@ -368,9 +368,10 @@ import { SwapState, SwapErrorAction } from '../../stores/Swaps'; import { formatDuration } from '../../lib/Time'; import { getColorClass } from '../../lib/AddressColor'; import { explorerAddrLink } from '../../lib/ExplorerUtils'; -import { assetToCurrency } from '../../lib/swap/utils/Assets'; +import { assetToCurrency, SupportedSwapAsset } from '../../lib/swap/utils/Assets'; import BitcoinSvg from './animation/bitcoin.svg'; import UsdcSvg from './animation/usdc.svg'; +import UsdtSvg from './animation/usdt.svg'; import BankSvg from './animation/bank.svg'; import MessageTransition from '../MessageTransition.vue'; @@ -385,7 +386,7 @@ export default defineComponent({ required: true, }, fromAsset: { - type: String as () => SwapAsset, + type: String as () => SupportedSwapAsset, required: true, }, fromAmount: { @@ -397,7 +398,7 @@ export default defineComponent({ default: '', }, toAsset: { - type: String as () => SwapAsset, + type: String as () => SupportedSwapAsset, required: true, }, toAmount: { @@ -482,6 +483,7 @@ export default defineComponent({ } case SwapAsset.BTC: return '#f7931a'; case SwapAsset.USDC_MATIC: return '#2775ca'; + case SwapAsset.USDT_MATIC: return '#009393'; case SwapAsset.EUR: return props.bankColor; default: return ''; } @@ -501,6 +503,7 @@ export default defineComponent({ } case SwapAsset.BTC: return '#f7931a'; case SwapAsset.USDC_MATIC: return '#2775ca'; + case SwapAsset.USDT_MATIC: return '#009393'; case SwapAsset.EUR: return props.bankColor; default: return ''; } @@ -511,6 +514,7 @@ export default defineComponent({ case SwapAsset.NIM: return 'Nimiq'; case SwapAsset.BTC: return 'Bitcoin'; case SwapAsset.USDC_MATIC: return 'USD Coin'; + case SwapAsset.USDT_MATIC: return 'Tether USD'; case SwapAsset.EUR: return 'Euro'; default: throw new Error(`Invalid asset ${asset}`); } @@ -631,9 +635,14 @@ export default defineComponent({ const errorActionText = computed(() => { if (!props.error) return false; - if (props.errorAction === SwapErrorAction.USDC_RESIGN_REDEEM - && props.swapState === SwapState.SETTLE_INCOMING - && props.toAsset === SwapAsset.USDC_MATIC + if (props.swapState === SwapState.SETTLE_INCOMING + && (( + props.errorAction === SwapErrorAction.USDC_RESIGN_REDEEM + && props.toAsset === SwapAsset.USDC_MATIC + ) || ( + props.errorAction === SwapErrorAction.USDT_RESIGN_REDEEM + && props.toAsset === SwapAsset.USDT_MATIC + )) ) { return context.root.$t('Restart payout process') as string; } @@ -658,6 +667,7 @@ export default defineComponent({ SwapAsset, BitcoinSvg, UsdcSvg, + UsdtSvg, BankSvg, BottomNoticeMessage, bottomNoticeMsg, diff --git a/src/components/swap/SwapBalanceBar.vue b/src/components/swap/SwapBalanceBar.vue index 880de4333..2b9c428ff 100644 --- a/src/components/swap/SwapBalanceBar.vue +++ b/src/components/swap/SwapBalanceBar.vue @@ -24,7 +24,7 @@
-
+
@@ -51,7 +51,7 @@
-
+
@@ -184,7 +184,11 @@ export default defineComponent({ setup(props, context) { const { addressInfos, selectAddress, activeAddressInfo } = useAddressStore(); const { accountBalance: btcAccountBalance, availableExternalAddresses } = useBtcAddressStore(); - const { addressInfo: usdcAddressInfo, accountUsdcBalance } = usePolygonAddressStore(); + const { + addressInfo: polygonAddressInfo, + accountUsdcBalance, + accountUsdtBridgedBalance, + } = usePolygonAddressStore(); const { exchangeRates, currency } = useFiatStore(); const root = ref(null); @@ -235,7 +239,7 @@ export default defineComponent({ }]; case SwapAsset.USDC_MATIC: return [{ - address: usdcAddressInfo.value?.address || 'usdc', + address: polygonAddressInfo.value?.address || 'usdc', balance: accountUsdcBalance.value, active: true, newFiatBalance: (props.newLeftBalance / 1e6) * leftExchangeRate.value, @@ -244,6 +248,17 @@ export default defineComponent({ fiatBalanceChange: ((props.newLeftBalance - accountUsdcBalance.value) / 1e6) * leftExchangeRate.value, }]; + case SwapAsset.USDT_MATIC: + return [{ + address: polygonAddressInfo.value?.address || 'usdt', + balance: accountUsdtBridgedBalance.value, + active: true, + newFiatBalance: (props.newLeftBalance / 1e6) * leftExchangeRate.value, + barColorClass: 'usdt', + balanceChange: (props.newLeftBalance - accountUsdtBridgedBalance.value), + fiatBalanceChange: ((props.newLeftBalance - accountUsdtBridgedBalance.value) / 1e6) + * leftExchangeRate.value, + }]; default: throw new Error('Invalid leftAsset'); } @@ -288,7 +303,7 @@ export default defineComponent({ }]; case SwapAsset.USDC_MATIC: return [{ - address: usdcAddressInfo.value?.address || 'usdc', + address: polygonAddressInfo.value?.address || 'usdc', balance: accountUsdcBalance.value, active: true, newFiatBalance: (props.newRightBalance / 1e6) * rightExchangeRate.value, @@ -297,6 +312,17 @@ export default defineComponent({ fiatBalanceChange: ((props.newRightBalance - accountUsdcBalance.value) / 1e6) * rightExchangeRate.value, }]; + case SwapAsset.USDT_MATIC: + return [{ + address: polygonAddressInfo.value?.address || 'usdt', + balance: accountUsdtBridgedBalance.value, + active: true, + newFiatBalance: (props.newRightBalance / 1e6) * rightExchangeRate.value, + barColorClass: 'usdt', + balanceChange: (props.newRightBalance - accountUsdtBridgedBalance.value), + fiatBalanceChange: ((props.newRightBalance - accountUsdtBridgedBalance.value) / 1e6) + * rightExchangeRate.value, + }]; default: throw new Error('Invalid rightAsset'); } @@ -373,7 +399,7 @@ export default defineComponent({ [SwapAsset.BTC]: 8, [SwapAsset.USDC]: 6, // For TS completeness [SwapAsset.USDC_MATIC]: 6, - [SwapAsset.USDT]: 6, + [SwapAsset.USDT_MATIC]: 6, [SwapAsset.EUR]: 2, // For TS completeness } as const; @@ -779,7 +805,8 @@ export default defineComponent({ } &.bitcoin, - &.usdc { + &.usdc, + &.usdt { --column-gap: 2rem; } } diff --git a/src/components/swap/SwapModal.vue b/src/components/swap/SwapModal.vue index 1a917060a..7f69ae15b 100644 --- a/src/components/swap/SwapModal.vue +++ b/src/components/swap/SwapModal.vue @@ -49,6 +49,7 @@ :nimFeeFiat="nimFeeFiat" :btcFeeFiat="btcFeeFiat" :usdcFeeFiat="usdcFeeFiat" + :usdtFeeFiat="usdtFeeFiat" :serviceSwapFeeFiat="serviceSwapFeeFiat" :serviceSwapFeePercentage="serviceSwapFeePercentage" :currency="currency" @@ -74,7 +75,7 @@ v-if="rightUnitsPerLeftCoin !== Infinity && rightUnitsPerLeftCoin !== -Infinity" slot="exchange-rate" class="exchange-rate"> - 1 {{ leftAsset }} = + 1 {{ assetToCurrency(leftAsset).toUpperCase() }} = @@ -186,7 +187,7 @@ :assets="[assetToCurrency(leftAsset), assetToCurrency(rightAsset)]" :buttonColor="kycUser ? 'purple' : 'light-blue'" :disabled="!canSign || currentlySigning" - :error="disabledAssetError || estimateError || swapError || usdcFeeError" + :error="disabledAssetError || estimateError || swapError || polygonFeeError" requireCompleteBtcHistory @click="sign" > @@ -334,8 +335,9 @@ import KycPrompt from '../kyc/KycPrompt.vue'; import KycOverlay from '../kyc/KycOverlay.vue'; import { getPolygonClient, - calculateFee as calculateUsdcFee, + calculateFee as calculatePolygonFee, getUsdcHtlcContract, + getUsdtBridgedHtlcContract, getPolygonBlockNumber, } from '../../ethers'; import { POLYGON_BLOCKS_PER_MINUTE, RelayServerInfo } from '../../lib/usdc/OpenGSN'; @@ -352,7 +354,7 @@ function getWalletEnabledAssets() { return [ SwapAsset.NIM, ...(config.enableBitcoin ? [SwapAsset.BTC] : []), - ...(config.polygon.enabled ? [SwapAsset.USDC_MATIC] : []), + ...(config.polygon.enabled ? [SwapAsset.USDC_MATIC, SwapAsset.USDT_MATIC] : []), ]; } @@ -399,6 +401,9 @@ export default defineComponent({ const swapHasUsdc = computed( () => leftAsset.value === SwapAsset.USDC_MATIC || rightAsset.value === SwapAsset.USDC_MATIC, ); + const swapHasUsdt = computed( + () => leftAsset.value === SwapAsset.USDT_MATIC || rightAsset.value === SwapAsset.USDT_MATIC, + ); const fixedAsset = ref(leftAsset.value); @@ -420,7 +425,7 @@ export default defineComponent({ const { accountBalance: accountBtcBalance, accountUtxos } = useBtcAddressStore(); const { activeAddressInfo, selectAddress, activeAddress } = useAddressStore(); const { - activeAddress: activeUsdcAddress, + activeAddress: activePolygonAddress, accountUsdcBalance, accountUsdtBridgedBalance, } = usePolygonAddressStore(); @@ -436,7 +441,8 @@ export default defineComponent({ const { limits, nimAddress: limitsNimAddress, recalculate: recalculateLimits } = useSwapLimits({ nimAddress: activeAddress.value!, - usdcAddress: activeUsdcAddress.value, + usdcAddress: activePolygonAddress.value, + usdtAddress: activePolygonAddress.value, }); // Re-run limit calculation when address changes (only NIM address can change within the active account) @@ -475,7 +481,7 @@ export default defineComponent({ [SwapAsset.BTC]: 8, [SwapAsset.USDC]: 6, // For TS completeness [SwapAsset.USDC_MATIC]: 6, - [SwapAsset.USDT]: 6, + [SwapAsset.USDT_MATIC]: 6, [SwapAsset.EUR]: 2, // For TS completeness } as const; @@ -654,6 +660,7 @@ export default defineComponent({ } if (fee) return fee; + if (asset === SwapAsset.USDT_MATIC) asset = SwapAsset.USDC_MATIC; if (assets.value) fee = assets.value[asset].feePerUnit; if (fee) return fee; @@ -661,7 +668,7 @@ export default defineComponent({ ? 0 // 0 NIM : asset === SwapAsset.BTC ? 1 // 1 sat - : 200e9; // 200 Gwei - For USDC it doesn't matter, since we get the fee from the network anyway + : 200e9; // 200 Gwei - For USDC/T it doesn't matter, since we get the fee from the network anyway } // 48 extra weight units for BTC HTLC funding tx @@ -718,10 +725,11 @@ export default defineComponent({ } break; case SwapAsset.USDC_MATIC: - if (usdcFeeStuff.value) fundingFee = usdcFeeStuff.value.fee; + case SwapAsset.USDT_MATIC: + if (polygonFeeStuff.value) fundingFee = polygonFeeStuff.value.fee; else if (!asPromise) fundingFee = 0; else fundingFee = new Promise((resolve) => { // eslint-disable-line curly - const stop = watch(usdcFeeStuff, (stuff) => { + const stop = watch(polygonFeeStuff, (stuff) => { if (!stuff) return; resolve(stuff.fee); stop(); @@ -742,10 +750,11 @@ export default defineComponent({ settlementFee = estimateFees(1, 1, feesPerUnit.btc || settlementFeePerUnit, 135); break; case SwapAsset.USDC_MATIC: - if (usdcFeeStuff.value) settlementFee = usdcFeeStuff.value.fee; + case SwapAsset.USDT_MATIC: + if (polygonFeeStuff.value) settlementFee = polygonFeeStuff.value.fee; else if (!asPromise) settlementFee = 0; else settlementFee = new Promise((resolve) => { // eslint-disable-line curly - const stop = watch(usdcFeeStuff, (stuff) => { + const stop = watch(polygonFeeStuff, (stuff) => { if (!stuff) return; resolve(stuff.fee); stop(); @@ -790,6 +799,7 @@ export default defineComponent({ break; } case SwapAsset.USDC_MATIC: + case SwapAsset.USDT_MATIC: fundingFee = 0; // TODO break; default: @@ -806,6 +816,7 @@ export default defineComponent({ settlementFee = estimateFees(1, 1, feesPerUnit.btc || settlementFeePerUnit, 135); break; case SwapAsset.USDC_MATIC: + case SwapAsset.USDT_MATIC: settlementFee = 0; // TODO break; default: @@ -866,12 +877,12 @@ export default defineComponent({ }; } - let usdcRelay = { + let polygonRelay = { relay: undefined as RelayServerInfo | undefined, timestamp: 0, }; - type UsdcFees = { + type PolygonFees = { /** Fee in USDC units */ fee: number, /** Gas limit in MATIC units */ @@ -881,44 +892,46 @@ export default defineComponent({ /** Relay details */ relay: RelayServerInfo, /** The method that these fees were calculated for */ - method: 'open' | 'openWithPermit' | 'redeemWithSecretInData', + method: 'open' | 'openWithPermit' | 'openWithApproval' | 'redeemWithSecretInData', }; - const usdcFeeStuff = ref(null); - const usdcFeeError = ref(null); + const polygonFeeStuff = ref(null); + const polygonFeeError = ref(null); // Used for Fastspot service fee calculation - const usdcPriceInWei = ref(null); - const usdcGasPrice = ref(null); + const stableUsdPriceInWei = ref(null); + const polygonGasPrice = ref(null); - async function calculateUsdcHtlcFee(forOpening: boolean, prevUsdcFees: UsdcFees | null) { - const prevMethod = prevUsdcFees?.method; + async function calculatePolygonHtlcFee(forOpening: boolean, prevPolygonFees: PolygonFees | null) { + const prevMethod = prevPolygonFees?.method; // Use the existing relay if it was selected in the last 5 minutes - const forceRelay = usdcRelay.timestamp > Date.now() - 5 * 60 * 1e3 - ? usdcRelay.relay + const forceRelay = polygonRelay.timestamp > Date.now() - 5 * 60 * 1e3 + ? polygonRelay.relay : undefined; - let method: 'open' | 'openWithPermit' | 'redeemWithSecretInData' = forOpening - ? 'openWithPermit' + let method: 'open' | 'openWithPermit' | 'openWithApproval' | 'redeemWithSecretInData' = forOpening + ? (stablecoin.value === CryptoCurrency.USDC ? 'openWithPermit' : 'openWithApproval') : 'redeemWithSecretInData'; if (forOpening) { - if (prevMethod === 'open' || prevMethod === 'openWithPermit') { + if (prevMethod === 'open' || prevMethod === 'openWithPermit' || prevMethod === 'openWithApproval') { // Allowance was already checked at the last fee calculation, reuse the previous result method = prevMethod; } else { // // Otherwise check allowance now // const client = await getPolygonClient(); // const allowance = await client.usdcToken.allowance( - // activeUsdcAddress.value!, + // activePolygonAddress.value!, // config.polygon.usdc.htlcContract, // ) as BigNumber; // if (allowance.gte(accountUsdcBalance.value)) method = 'open'; } } - const htlcContract = await getUsdcHtlcContract(); + const htlcContract = stablecoin.value === CryptoCurrency.USDC + ? await getUsdcHtlcContract() + : await getUsdtBridgedHtlcContract(); const { fee, @@ -926,18 +939,25 @@ export default defineComponent({ gasPrice, relay, usdPrice, - } = await calculateUsdcFee(config.polygon.usdc.tokenContract, method, forceRelay, htlcContract); + } = await calculatePolygonFee( + stablecoin.value === CryptoCurrency.USDC + ? config.polygon.usdc.tokenContract + : config.polygon.usdt_bridged.tokenContract, + method, + forceRelay, + htlcContract, + ); if (!forceRelay) { // Store the new relay - usdcRelay = { + polygonRelay = { relay, timestamp: Date.now(), }; } - usdcPriceInWei.value = usdPrice.toNumber(); - usdcGasPrice.value = gasPrice.toNumber(); + stableUsdPriceInWei.value = usdPrice.toNumber(); + polygonGasPrice.value = gasPrice.toNumber(); return { fee: fee.toNumber(), @@ -948,68 +968,68 @@ export default defineComponent({ }; } - let usdcFeeUpdateTimeout = -1; // -1: stopped; 0: to be started; >0: timer id - async function startUsdcFeeUpdates() { - window.clearTimeout(usdcFeeUpdateTimeout); // Reset potentially existing update timeout. - usdcFeeUpdateTimeout = 0; // 0: timer is to be started after the initial update - if (![leftAsset.value, rightAsset.value].includes(SwapAsset.USDC_MATIC)) { - stopUsdcFeeUpdates(); + let polygonFeeUpdateTimeout = -1; // -1: stopped; 0: to be started; >0: timer id + async function startPolygonFeeUpdates() { + window.clearTimeout(polygonFeeUpdateTimeout); // Reset potentially existing update timeout. + polygonFeeUpdateTimeout = 0; // 0: timer is to be started after the initial update + if (!swapHasUsdc.value && !swapHasUsdt.value) { + stopPolygonFeeUpdates(); return false; } try { if (!currentlySigning.value) { - // Update USDC fees if not already signing a swap suggestion. - const forOpening = (direction.value === SwapDirection.LEFT_TO_RIGHT - ? leftAsset - : rightAsset).value === SwapAsset.USDC_MATIC; - const prevUsdcFeeStuff = usdcFeeStuff.value; - usdcFeeStuff.value = null; - usdcFeeStuff.value = await calculateUsdcHtlcFee(forOpening, prevUsdcFeeStuff); - usdcFeeError.value = null; + // Update USDC/T fees if not already signing a swap suggestion. + const forOpening = [SwapAsset.USDC_MATIC, SwapAsset.USDT_MATIC].includes( + (direction.value === SwapDirection.LEFT_TO_RIGHT ? leftAsset : rightAsset).value, + ); + const prevPolygonFeeStuff = polygonFeeStuff.value; + polygonFeeStuff.value = null; + polygonFeeStuff.value = await calculatePolygonHtlcFee(forOpening, prevPolygonFeeStuff); + polygonFeeError.value = null; } - if (usdcFeeUpdateTimeout === 0) { + if (polygonFeeUpdateTimeout === 0) { // Schedule next update in 30s if timer is still to be started and has not been started yet. - usdcFeeUpdateTimeout = window.setTimeout(startUsdcFeeUpdates, 30e3); + polygonFeeUpdateTimeout = window.setTimeout(startPolygonFeeUpdates, 30e3); } - return true; // return true if USDC was successfully updated on first attempt. + return true; // return true if USDC/T was successfully updated on first attempt. } catch (e: unknown) { - if (![leftAsset.value, rightAsset.value].includes(SwapAsset.USDC_MATIC)) { - // USDC is not selected anymore. - stopUsdcFeeUpdates(); + if (!swapHasUsdc.value && !swapHasUsdt.value) { + // USDC/T is not selected anymore. + stopPolygonFeeUpdates(); return false; } - usdcFeeError.value = context.root.$t( - 'Failed to fetch USDC fees. Retrying... (Error: {message})', + polygonFeeError.value = context.root.$t( + 'Failed to fetch Polygon fees. Retrying... (Error: {message})', { message: e instanceof Error ? e.message : String(e) }, ) as string; - if (usdcFeeUpdateTimeout === 0) { + if (polygonFeeUpdateTimeout === 0) { // Retry in 10s if timer is still to be started and has not been started yet. - usdcFeeUpdateTimeout = window.setTimeout(startUsdcFeeUpdates, 10e3); + polygonFeeUpdateTimeout = window.setTimeout(startPolygonFeeUpdates, 10e3); } return false; } } - function stopUsdcFeeUpdates() { - window.clearTimeout(usdcFeeUpdateTimeout); - usdcFeeUpdateTimeout = -1; // -1: timer stopped - usdcFeeStuff.value = null; - usdcFeeError.value = null; + function stopPolygonFeeUpdates() { + window.clearTimeout(polygonFeeUpdateTimeout); + polygonFeeUpdateTimeout = -1; // -1: timer stopped + polygonFeeStuff.value = null; + polygonFeeError.value = null; } watch([leftAsset, rightAsset], () => { - if ([leftAsset.value, rightAsset.value].includes(SwapAsset.USDC_MATIC)) { + if (swapHasUsdc.value || swapHasUsdt.value) { // (Re)start USDC fee updates if USDC was selected or the USDC swap direction switched. - startUsdcFeeUpdates(); + startPolygonFeeUpdates(); } else { - stopUsdcFeeUpdates(); + stopPolygonFeeUpdates(); } }); - onBeforeUnmount(stopUsdcFeeUpdates); + onBeforeUnmount(stopPolygonFeeUpdates); // watch( - // usdcFeeStuff, + // polygonFeeStuff, // (stuff) => console.log('Got new USDC fee:', stuff?.fee), // { lazy: true }, // ); @@ -1097,7 +1117,7 @@ export default defineComponent({ case SwapAsset.BTC: return accountBtcBalance.value; case SwapAsset.USDC: return 0; // not supported for swapping case SwapAsset.USDC_MATIC: return accountUsdcBalance.value; - case SwapAsset.USDT: return accountUsdtBridgedBalance.value; + case SwapAsset.USDT_MATIC: return accountUsdtBridgedBalance.value; case SwapAsset.EUR: return 0; } } @@ -1212,8 +1232,9 @@ export default defineComponent({ const myLeftFeeFiat = computed(() => { let fee: number; if (!estimate.value) { - if (leftAsset.value === SwapAsset.USDC_MATIC) fee = usdcFeeStuff.value?.fee || 0; - else { + if (leftAsset.value === SwapAsset.USDC_MATIC || leftAsset.value === SwapAsset.USDT_MATIC) { + fee = polygonFeeStuff.value?.fee || 0; + } else { const { fundingFee, settlementFee } = calculateMyFees(); fee = direction.value === SwapDirection.LEFT_TO_RIGHT ? fundingFee : settlementFee; } @@ -1228,8 +1249,9 @@ export default defineComponent({ const myRightFeeFiat = computed(() => { let fee: number; if (!estimate.value) { - if (rightAsset.value === SwapAsset.USDC_MATIC) fee = usdcFeeStuff.value?.fee || 0; - else { + if (rightAsset.value === SwapAsset.USDC_MATIC || rightAsset.value === SwapAsset.USDT_MATIC) { + fee = polygonFeeStuff.value?.fee || 0; + } else { const { fundingFee, settlementFee } = calculateMyFees(); fee = direction.value === SwapDirection.LEFT_TO_RIGHT ? settlementFee : fundingFee; } @@ -1245,17 +1267,17 @@ export default defineComponent({ const serviceLeftFeeFiat = computed(() => { let fee: number; if (!estimate.value) { - if (leftAsset.value === SwapAsset.USDC_MATIC) { + if (leftAsset.value === SwapAsset.USDC_MATIC || leftAsset.value === SwapAsset.USDT_MATIC) { if ( - !(usdcGasPrice.value || assets.value?.[SwapAsset.USDC_MATIC].feePerUnit) - || !usdcPriceInWei.value + !(polygonGasPrice.value || assets.value?.[leftAsset.value].feePerUnit) + || !stableUsdPriceInWei.value ) { return 0; } - const gasPrice = assets.value?.[SwapAsset.USDC_MATIC].feePerUnit || usdcGasPrice.value!; + const gasPrice = assets.value?.[leftAsset.value].feePerUnit || polygonGasPrice.value!; const serviceGasLimit = direction.value === SwapDirection.LEFT_TO_RIGHT ? 72548 : 227456; - fee = Math.ceil((gasPrice * serviceGasLimit) / usdcPriceInWei.value); + fee = Math.ceil((gasPrice * serviceGasLimit) / stableUsdPriceInWei.value); } else { const { fundingFee, settlementFee } = calculateServiceFees(); fee = direction.value === SwapDirection.LEFT_TO_RIGHT ? settlementFee : fundingFee; @@ -1273,17 +1295,18 @@ export default defineComponent({ const serviceRightFeeFiat = computed(() => { let fee: number; if (!estimate.value) { - if (rightAsset.value === SwapAsset.USDC_MATIC) { + if (rightAsset.value === SwapAsset.USDC_MATIC || rightAsset.value === SwapAsset.USDT_MATIC) { if ( - !(usdcGasPrice.value || assets.value?.[SwapAsset.USDC_MATIC].feePerUnit) - || !usdcPriceInWei.value + !(polygonGasPrice.value || assets.value?.[rightAsset.value].feePerUnit) + + || !stableUsdPriceInWei.value ) { return 0; } - const gasPrice = assets.value?.[SwapAsset.USDC_MATIC].feePerUnit || usdcGasPrice.value!; + const gasPrice = assets.value?.[rightAsset.value].feePerUnit || polygonGasPrice.value!; const serviceGasLimit = direction.value === SwapDirection.RIGHT_TO_LEFT ? 72548 : 227456; - fee = Math.ceil((gasPrice * serviceGasLimit) / usdcPriceInWei.value); + fee = Math.ceil((gasPrice * serviceGasLimit) / stableUsdPriceInWei.value); } else { const { fundingFee, settlementFee } = calculateServiceFees(); fee = direction.value === SwapDirection.RIGHT_TO_LEFT ? settlementFee : fundingFee; @@ -1328,12 +1351,19 @@ export default defineComponent({ const nimFeeFiat = computed(() => feeFiat(SwapAsset.NIM)); const btcFeeFiat = computed(() => feeFiat(SwapAsset.BTC)); const usdcFeeFiat = computed(() => feeFiat(SwapAsset.USDC_MATIC)); + const usdtFeeFiat = computed(() => feeFiat(SwapAsset.USDT_MATIC)); const totalFeeFiat = computed(() => - (nimFeeFiat.value || 0) + (btcFeeFiat.value || 0) + (usdcFeeFiat.value || 0) + serviceSwapFeeFiat.value); + (nimFeeFiat.value || 0) + + (btcFeeFiat.value || 0) + + (usdcFeeFiat.value || 0) + + (usdtFeeFiat.value || 0) + + serviceSwapFeeFiat.value, + ); const feeIsLoading = computed(() => { if (swapHasBtc.value && !btcFeeFiat.value) return true; if (swapHasUsdc.value && !usdcFeeFiat.value) return true; + if (swapHasUsdt.value && !usdtFeeFiat.value) return true; return false; }); @@ -1375,7 +1405,7 @@ export default defineComponent({ // + `(disabledAssetError: ${disabledAssetError.value})\n` // + `!estimateError: ${!estimateError.value} (estimateError: ${estimateError.value})\n` // + `!swapError: ${!swapError.value} (swapError: ${swapError.value})\n` - // + `!usdcFeeError: ${!usdcFeeError.value} (usdcFeeError: ${usdcFeeError.value})\n` + // + `!polygonFeeError: ${!polygonFeeError.value} (polygonFeeError: ${polygonFeeError.value})\n` // + `!!estimate: ${!!estimate.value} (estimate: ${estimate.value})\n` // + `!!limits.current.usd: ${!!limits.value?.current.usd} (limits: ${limits.value})\n` // + `!fetchingEstimate: ${!fetchingEstimate.value} (fetchingEstimate: ${fetchingEstimate.value})\n` @@ -1383,10 +1413,11 @@ export default defineComponent({ // + `newRightBalance>=0: ${newRightBalance.value>=0} (newRightBalance: ${newRightBalance.value})`, // ); // Don't need to wait for fees because they're calculated from the estimate and swapSuggestion for NIM and - // BTC, and for USDC waiting for usdcFeeStuff is covered by fetchingEstimate via calculateMyFees in - // updateEstimate, which waits for usdcFeeStuff (but usdcFeeStuff is also re-fetched in sign() anyways). + // BTC, and for USDC waiting for polygonFeeStuff is covered by fetchingEstimate via calculateMyFees in + // updateEstimate, which waits for polygonFeeStuff (but polygonFeeStuff is also re-fetched in sign() + // anyways). return config.fastspot.enabled - && !disabledAssetError.value && !estimateError.value && !swapError.value && !usdcFeeError.value + && !disabledAssetError.value && !estimateError.value && !swapError.value && !polygonFeeError.value && estimate.value && limits.value?.current.usd && !fetchingEstimate.value @@ -1402,11 +1433,11 @@ export default defineComponent({ // Get up-to-date fees for USDC let wasFeeUpdateSuccessful = Promise.resolve(true); - if ([leftAsset.value, rightAsset.value].includes(SwapAsset.USDC_MATIC) && usdcFeeStuff.value) { - // Fetch new fees, if no update is currently in process already (in which case usdcFeeStuff would be - // null as it's cleared in startUsdcFeeUpdates). If an update is already in process, the result is being - // awaited via the promises returned by calculateMyFees. - wasFeeUpdateSuccessful = startUsdcFeeUpdates(); + if ((swapHasUsdc.value || swapHasUsdt.value) && polygonFeeStuff.value) { + // Fetch new fees, if no update is currently in process already (in which case polygonFeeStuff would be + // null as it's cleared in startPolygonFeeUpdates). If an update is already in process, the result is + // being awaited via the promises returned by calculateMyFees. + wasFeeUpdateSuccessful = startPolygonFeeUpdates(); } currentlySigning.value = true; @@ -1417,8 +1448,8 @@ export default defineComponent({ if (!await wasFeeUpdateSuccessful) { // If first attempt to update fee was not successful, abort signing. An error message will be shown - // in the UI via usdcFeeError. - reject(new Error(usdcFeeError.value || undefined)); + // in the UI via polygonFeeError. + reject(new Error(polygonFeeError.value || undefined)); } try { @@ -1436,6 +1467,14 @@ export default defineComponent({ ); } + if (typeof from !== 'string' && 'USDT_MATIC' in from) { + // Ensure we send only what's possible with the updated fee + from[SwapAsset.USDT_MATIC] = Math.min( + from[SwapAsset.USDT_MATIC]!, + (accountUsdtBridgedBalance.value - await fees.fundingFee) / 1e6, + ); + } + swapSuggestion = await createSwap( from as RequestAsset, // Need to force one of the function signatures to as SupportedSwapAsset, @@ -1558,7 +1597,7 @@ export default defineComponent({ getPolygonClient(), getUsdcHtlcContract(), ]); - const fromAddress = activeUsdcAddress.value!; + const fromAddress = activePolygonAddress.value!; const [ usdcNonce, @@ -1570,7 +1609,7 @@ export default defineComponent({ getPolygonBlockNumber(), ]); - const { fee, gasLimit, gasPrice, relay, method } = usdcFeeStuff.value!; + const { fee, gasLimit, gasPrice, relay, method } = polygonFeeStuff.value!; if (method !== 'open' && method !== 'openWithPermit') { throw new Error('Wrong USDC contract method'); } @@ -1633,6 +1672,86 @@ export default defineComponent({ }; } + if (swapSuggestion.from.asset === SwapAsset.USDT_MATIC) { + const [client, htlcContract] = await Promise.all([ + getPolygonClient(), + getUsdtBridgedHtlcContract(), + ]); + const fromAddress = activePolygonAddress.value!; + + const [ + usdtNonce, + forwarderNonce, + blockHeight, + ] = await Promise.all([ + client.usdtBridgedToken.getNonce(fromAddress) as Promise, + htlcContract.getNonce(fromAddress) as Promise, + getPolygonBlockNumber(), + ]); + + const { fee, gasLimit, gasPrice, relay, method } = polygonFeeStuff.value!; + if (method !== 'open' && method !== 'openWithApproval') { + throw new Error('Wrong USDT contract method'); + } + + // Zeroed data fields are replaced by Fastspot's proposed data (passed in from Hub) in + // Keyguard's SwapIFrameApi. + const data = htlcContract.interface.encodeFunctionData(method, [ + /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', + /* address token */ config.polygon.usdt_bridged.tokenContract, + /* uint256 amount */ swapSuggestion.from.amount, + /* address refundAddress */ fromAddress, + /* address recipientAddress */ '0x0000000000000000000000000000000000000000', + /* bytes32 hash */ '0x0000000000000000000000000000000000000000000000000000000000000000', + /* uint256 timeout */ 0, + /* uint256 fee */ fee, + ...(method === 'openWithApproval' ? [ + // // Approve the maximum possible amount so afterwards we can use the `open` method for + // // lower fees + // /* uint256 approval */ client.ethers + // .BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + /* uint256 approval */ swapSuggestion.from.amount + fee, + + /* bytes32 sigR */ '0x0000000000000000000000000000000000000000000000000000000000000000', + /* bytes32 sigS */ '0x0000000000000000000000000000000000000000000000000000000000000000', + /* uint8 sigV */ 0, + ] : []), + ]); + + const relayRequest: RelayRequest = { + request: { + from: fromAddress, + to: config.polygon.usdt_bridged.htlcContract, + data, + value: '0', + nonce: forwarderNonce.toString(), + gas: gasLimit.toString(), + validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + }, + relayData: { + gasPrice: gasPrice.toString(), + pctRelayFee: relay.pctRelayFee.toString(), + baseRelayFee: relay.baseRelayFee.toString(), + relayWorker: relay.relayWorkerAddress, + paymaster: config.polygon.usdt_bridged.htlcContract, + paymasterData: '0x', + clientId: Math.floor(Math.random() * 1e6).toString(10), + forwarder: config.polygon.usdt_bridged.htlcContract, + }, + }; + + fund = { + type: SwapAsset.USDT_MATIC, + ...relayRequest, + ...(method === 'openWithApproval' ? { + approval: { + tokenNonce: usdtNonce.toNumber(), + }, + } : null), + }; + } + if (swapSuggestion.to.asset === SwapAsset.NIM) { const nimiqClient = await getNetworkClient(); await nimiqClient.waitForConsensusEstablished(); @@ -1673,7 +1792,7 @@ export default defineComponent({ if (swapSuggestion.to.asset === SwapAsset.USDC_MATIC) { const htlcContract = await getUsdcHtlcContract(); - const toAddress = activeUsdcAddress.value!; + const toAddress = activePolygonAddress.value!; const [ forwarderNonce, @@ -1683,7 +1802,7 @@ export default defineComponent({ getPolygonBlockNumber(), ]); - const { fee, gasLimit, gasPrice, relay, method } = usdcFeeStuff.value!; + const { fee, gasLimit, gasPrice, relay, method } = polygonFeeStuff.value!; if (method !== 'redeemWithSecretInData') { throw new Error('Wrong USDC contract method'); } @@ -1724,6 +1843,59 @@ export default defineComponent({ }; } + if (swapSuggestion.to.asset === SwapAsset.USDT_MATIC) { + const htlcContract = await getUsdtBridgedHtlcContract(); + const toAddress = activePolygonAddress.value!; + + const [ + forwarderNonce, + blockHeight, + ] = await Promise.all([ + htlcContract.getNonce(toAddress) as Promise, + getPolygonBlockNumber(), + ]); + + const { fee, gasLimit, gasPrice, relay, method } = polygonFeeStuff.value!; + if (method !== 'redeemWithSecretInData') { + throw new Error('Wrong USDT contract method'); + } + + const data = htlcContract.interface.encodeFunctionData(method, [ + /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', + /* address target */ toAddress, + /* uint256 fee */ fee, + ]); + + const relayRequest: RelayRequest = { + request: { + from: toAddress, + to: config.polygon.usdt_bridged.htlcContract, + data, + value: '0', + nonce: forwarderNonce.toString(), + gas: gasLimit.toString(), + validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + }, + relayData: { + gasPrice: gasPrice.toString(), + pctRelayFee: relay.pctRelayFee.toString(), + baseRelayFee: relay.baseRelayFee.toString(), + relayWorker: relay.relayWorkerAddress, + paymaster: config.polygon.usdt_bridged.htlcContract, + paymasterData: '0x', + clientId: Math.floor(Math.random() * 1e6).toString(10), + forwarder: config.polygon.usdt_bridged.htlcContract, + }, + }; + + redeem = { + type: SwapAsset.USDT_MATIC, + ...relayRequest, + amount: swapSuggestion.to.amount - swapSuggestion.to.fee, + }; + } + if (!fund || !redeem) { reject(new Error('UNEXPECTED: No funding or redeeming data objects')); return; @@ -1767,9 +1939,10 @@ export default defineComponent({ bitcoinAccount: { balance: accountBtcBalance.value, }, - polygonAddresses: activeUsdcAddress.value ? [{ - address: activeUsdcAddress.value, + polygonAddresses: activePolygonAddress.value ? [{ + address: activePolygonAddress.value, usdcBalance: accountUsdcBalance.value, + usdtBalance: accountUsdtBridgedBalance.value, }] : [], }; @@ -1839,15 +2012,15 @@ export default defineComponent({ ? fund.inputs.reduce((sum, input) => sum + input.value, 0) - fund.output.value - (fund.changeOutput?.value || 0) - : fund.type === SwapAsset.USDC_MATIC - ? usdcFeeStuff.value!.fee + : fund.type === SwapAsset.USDC_MATIC || fund.type === SwapAsset.USDT_MATIC + ? polygonFeeStuff.value!.fee : 0; confirmedSwap.to.fee = redeem.type === SwapAsset.NIM ? redeem.fee : redeem.type === SwapAsset.BTC ? redeem.input.value - redeem.output.value - : redeem.type === SwapAsset.USDC_MATIC - ? usdcFeeStuff.value!.fee + : redeem.type === SwapAsset.USDC_MATIC || redeem.type === SwapAsset.USDT_MATIC + ? polygonFeeStuff.value!.fee : 0; } catch (error) { if (config.reportToSentry) captureException(error); @@ -1879,17 +2052,17 @@ export default defineComponent({ watchtowerNotified: false, fundingSerializedTx: 'serializedTx' in fundingSignedTx ? fundingSignedTx.serializedTx // NIM & BTC - : JSON.stringify({ // USDC + : JSON.stringify({ // USDC/T request: fundingSignedTx.message, signature: fundingSignedTx.signature, - relayUrl: usdcFeeStuff.value!.relay.url, + relayUrl: polygonFeeStuff.value!.relay.url, }), settlementSerializedTx: 'serializedTx' in redeemingSignedTx ? redeemingSignedTx.serializedTx // NIM & BTC - : JSON.stringify({ // USDC + : JSON.stringify({ // USDC/T request: redeemingSignedTx.message, signature: redeemingSignedTx.signature, - relayUrl: usdcFeeStuff.value!.relay.url, + relayUrl: polygonFeeStuff.value!.relay.url, }), nimiqProxySerializedTx: signedTransactions.nimProxy?.serializedTx, }); @@ -1906,7 +2079,10 @@ export default defineComponent({ } // In case of a Polygon signed message, we need to restructure the `request` format - if (confirmedSwap.to.asset === SwapAsset.USDC_MATIC) { + if ( + confirmedSwap.to.asset === SwapAsset.USDC_MATIC + || confirmedSwap.to.asset === SwapAsset.USDT_MATIC + ) { const { request, signature, relayUrl } = JSON.parse(settlementSerializedTx); const { relayData, ...relayRequest } = request; settlementSerializedTx = JSON.stringify({ @@ -1989,10 +2165,8 @@ export default defineComponent({ function getButtonGroupOptions(otherSide: SupportedSwapAsset) { const otherAssetBalance = accountBalance(otherSide); return getWalletEnabledAssets().reduce((result, asset) => { - if ( - asset === SwapAsset.USDC_MATIC - && (!stablecoin.value || stablecoin.value === CryptoCurrency.USDT) - ) return result; + if (asset === SwapAsset.USDC_MATIC && stablecoin.value !== CryptoCurrency.USDC) return result; + if (asset === SwapAsset.USDT_MATIC && stablecoin.value !== CryptoCurrency.USDT) return result; return { ...result, @@ -2004,6 +2178,7 @@ export default defineComponent({ // The asset is not activated in the active account. (asset === SwapAsset.BTC && !hasBitcoinAddresses.value) || (asset === SwapAsset.USDC_MATIC && !hasPolygonAddresses.value) + || (asset === SwapAsset.USDT_MATIC && !hasPolygonAddresses.value) ) || ( // Asset pair has no balance to swap. !otherAssetBalance && !accountBalance(asset as SupportedSwapAsset) @@ -2033,7 +2208,7 @@ export default defineComponent({ } const swapIsNotSupported = computed(() => activeAccountInfo.value?.type === AccountType.LEDGER - && (leftAsset.value === SwapAsset.USDC_MATIC || rightAsset.value === SwapAsset.USDC_MATIC)); + && (swapHasUsdc.value || swapHasUsdt.value)); const disabledSwap = computed(() => { const leftRate = exchangeRates.value[assetToCurrency(leftAsset.value)][currency.value]!; @@ -2052,6 +2227,9 @@ export default defineComponent({ if (swap.value?.errorAction === SwapErrorAction.USDC_RESIGN_REDEEM) { resignUsdcRedeemTransaction(); } + if (swap.value?.errorAction === SwapErrorAction.USDT_RESIGN_REDEEM) { + resignUsdtRedeemTransaction(); + } } async function resignUsdcRedeemTransaction() { @@ -2077,7 +2255,7 @@ export default defineComponent({ const toAddress = usdcHtlc.redeemAddress; // Unset stored relay so we can select a new one that hopefully works then - usdcRelay = { + polygonRelay = { relay: undefined, timestamp: 0, }; @@ -2089,7 +2267,7 @@ export default defineComponent({ ] = await Promise.all([ htlcContract.getNonce(toAddress) as Promise, getPolygonBlockNumber(), - calculateUsdcHtlcFee(false, null), + calculatePolygonHtlcFee(false, null), ]); if (method !== 'redeemWithSecretInData') { @@ -2198,6 +2376,150 @@ export default defineComponent({ } } + async function resignUsdtRedeemTransaction() { + if (!swap.value) { + console.warn('No swap found'); // eslint-disable-line no-console + return; + } + const usdtHtlc = swap.value.contracts[SwapAsset.USDT_MATIC] as Contract | undefined; + if (!usdtHtlc) { + console.warn('No USDT HTLC found in swap', swap.value); // eslint-disable-line no-console + return; + } + if (usdtHtlc.direction !== 'receive') { + console.warn('USDT HTLC is not a receive HTLC', usdtHtlc); // eslint-disable-line no-console + return; + } + + let relayUrl: string; + + // eslint-disable-next-line no-async-promise-executor + const request = new Promise>(async (resolve) => { + const htlcContract = await getUsdtBridgedHtlcContract(); // This promise is already resolved + const toAddress = usdtHtlc.redeemAddress; + + // Unset stored relay so we can select a new one that hopefully works then + polygonRelay = { + relay: undefined, + timestamp: 0, + }; + + const [ + forwarderNonce, + blockHeight, + { fee, gasLimit, gasPrice, relay, method }, + ] = await Promise.all([ + htlcContract.getNonce(toAddress) as Promise, + getPolygonBlockNumber(), + calculatePolygonHtlcFee(false, null), + ]); + + if (method !== 'redeemWithSecretInData') { + throw new Error('Wrong USDT contract method'); + } + + relayUrl = relay.url; + + const data = htlcContract.interface.encodeFunctionData(method, [ + /* bytes32 id */ usdtHtlc.htlc.address, + /* address target */ toAddress, + /* uint256 fee */ fee, + ]); + + const relayRequest: RelayRequest = { + request: { + from: toAddress, + to: config.polygon.usdt_bridged.htlcContract, + data, + value: '0', + nonce: forwarderNonce.toString(), + gas: gasLimit.toString(), + validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + }, + relayData: { + gasPrice: gasPrice.toString(), + pctRelayFee: relay.pctRelayFee.toString(), + baseRelayFee: relay.baseRelayFee.toString(), + relayWorker: relay.relayWorkerAddress, + paymaster: config.polygon.usdt_bridged.htlcContract, + paymasterData: '0x', + clientId: Math.floor(Math.random() * 1e6).toString(10), + forwarder: config.polygon.usdt_bridged.htlcContract, + }, + }; + + resolve({ + ...relayRequest, + amount: swap.value!.to.amount - swap.value!.to.fee, + senderLabel: 'Swap HTLC', + }); + }); + + const signedTransaction = await signPolygonTransaction(request); + if (!signedTransaction) return; + + if (!swap.value) { + console.warn('No swap found after signing'); // eslint-disable-line no-console + return; + } + + useSwapsStore().setActiveSwap({ + ...swap.value, + settlementSerializedTx: JSON.stringify({ + request: signedTransaction.message, + signature: signedTransaction.signature, + relayUrl: relayUrl!, + }), + error: undefined, + errorAction: undefined, + }); + + if (config.fastspot.watchtowerEndpoint) { + let settlementSerializedTx = swap.value.settlementSerializedTx!; + + // In case of a Polygon signed message, we need to restructure the `request` format + if (swap.value.to.asset === SwapAsset.USDT_MATIC) { + // eslint-disable-next-line @typescript-eslint/no-shadow + const { request, signature, relayUrl } = JSON.parse(settlementSerializedTx); + const { relayData, ...relayRequest } = request; + settlementSerializedTx = JSON.stringify({ + request: { + request: relayRequest as ForwardRequest, + relayData, + }, + signature, + relayUrl, + }); + } + + // Send redeem transaction to watchtower + fetch(`${config.fastspot.watchtowerEndpoint}/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: swap.value.id, + endpoint: new URL(config.fastspot.apiEndpoint).host, + apikey: config.fastspot.apiKey, + redeem: settlementSerializedTx, + }), + }).then(async (response) => { + if (!response.ok) { + throw new Error((await response.json()).message); + } + + setActiveSwap({ + ...swap.value!, + watchtowerNotified: true, + }); + console.debug('Swap watchtower notified'); // eslint-disable-line no-console + }).catch((error) => { + if (config.reportToSentry) captureException(error); + else console.error(error); // eslint-disable-line no-console + }); + } + } + return { onClose, leftAsset, @@ -2223,6 +2545,7 @@ export default defineComponent({ nimFeeFiat, btcFeeFiat, usdcFeeFiat, + usdtFeeFiat, totalFeeFiat, feeSmallerThanSmUnit, fiatSmUnit, @@ -2242,7 +2565,7 @@ export default defineComponent({ estimateError, swap, swapError, - usdcFeeError, + polygonFeeError, canSign, sign, cancel, diff --git a/src/components/swap/SwapNotification.vue b/src/components/swap/SwapNotification.vue index 4505486b6..f8cc45df6 100644 --- a/src/components/swap/SwapNotification.vue +++ b/src/components/swap/SwapNotification.vue @@ -79,12 +79,14 @@ import { getServerTime } from '../../lib/Time'; import { usePolygonNetworkStore } from '../../stores/PolygonNetwork'; import { getUsdcHtlcContract, + getUsdtBridgedHtlcContract, getPolygonBlockNumber, getPolygonClient, receiptToTransaction, sendTransaction as sendPolygonTransaction, } from '../../ethers'; import { useUsdcTransactionsStore, Transaction as UsdcTransaction } from '../../stores/UsdcTransactions'; +import { useUsdtTransactionsStore, Transaction as UsdtTransaction } from '../../stores/UsdtTransactions'; import { POLYGON_BLOCKS_PER_MINUTE } from '../../lib/usdc/OpenGSN'; enum SwapError { @@ -152,8 +154,11 @@ export default defineComponent({ } case SwapAsset.BTC: case SwapAsset.USDC_MATIC: + case SwapAsset.USDT_MATIC: case SwapAsset.EUR: { - const { timeout } = contract as Contract; + const { timeout } = contract as Contract< + SwapAsset.BTC | SwapAsset.USDC_MATIC | SwapAsset.USDT_MATIC | SwapAsset.EUR + >; if (timeout <= timestamp) return true; remainingTimes.push(timeout - timestamp); break; @@ -228,7 +233,7 @@ export default defineComponent({ return consensusErrorMsg('Bitcoin'); } if ( - swap.to.asset === SwapAsset.USDC_MATIC + (swap.to.asset === SwapAsset.USDC_MATIC || swap.to.asset === SwapAsset.USDT_MATIC) && usePolygonNetworkStore().state.consensus !== 'established' ) { return consensusErrorMsg('Polygon'); @@ -243,7 +248,7 @@ export default defineComponent({ return consensusErrorMsg('Bitcoin'); } if ( - swap.from.asset === SwapAsset.USDC_MATIC + (swap.from.asset === SwapAsset.USDC_MATIC || swap.from.asset === SwapAsset.USDT_MATIC) && usePolygonNetworkStore().state.consensus !== 'established' ) { return consensusErrorMsg('Polygon'); @@ -263,8 +268,9 @@ export default defineComponent({ switch (asset) { case SwapAsset.NIM: return getNetworkClient() as Promise>; case SwapAsset.BTC: return getElectrumClient(); - case SwapAsset.USDC_MATIC: { - const { timeout } = swap.contracts[SwapAsset.USDC_MATIC]!; + case SwapAsset.USDC_MATIC: + case SwapAsset.USDT_MATIC: { + const { timeout } = swap.contracts[asset]!; const secondsUntilTimeout = timeout - Math.floor(Date.now() / 1e3); const blocksUntilTimeout = Math.ceil((secondsUntilTimeout / 60) * POLYGON_BLOCKS_PER_MINUTE); @@ -274,7 +280,9 @@ export default defineComponent({ const blockHeightAtStart = blockHeightAtTimeout - blocksOfValidity; return { - htlcContract: await getUsdcHtlcContract(), + htlcContract: asset === SwapAsset.USDC_MATIC + ? await getUsdcHtlcContract() + : await getUsdtBridgedHtlcContract(), currentBlock: () => getPolygonBlockNumber(), startBlock: blockHeightAtStart, endBlock: blockHeightAtTimeout > currentHeight ? undefined : blockHeightAtTimeout, @@ -291,8 +299,8 @@ export default defineComponent({ // The listener should run in the background, even if the transaction-sending itself fails and // `processSwap` thus throws. To keep the same listener across retries, it is scoped outside the // `processSwap` function. - let usdcListener: Promise> | undefined; - let isUsdcListenerResolved = false; + let polygonListener: Promise> | undefined; + let isPolygonListenerResolved = false; async function processSwap() { if (!activeSwap.value || !activeSwap.value.id || !activeSwap.value.from) { @@ -308,6 +316,7 @@ export default defineComponent({ const swapsNim = [activeSwap.value!.from.asset, activeSwap.value!.to.asset].includes(SwapAsset.NIM); const swapsBtc = [activeSwap.value!.from.asset, activeSwap.value!.to.asset].includes(SwapAsset.BTC); const swapsUsdc = [activeSwap.value!.from.asset, activeSwap.value!.to.asset].includes(SwapAsset.USDC_MATIC); + const swapsUsdt = [activeSwap.value!.from.asset, activeSwap.value!.to.asset].includes(SwapAsset.USDT_MATIC); // const swapsEur = [activeSwap.value!.from.asset, activeSwap.value!.to.asset].includes(SwapAsset.EUR); // Await Nimiq and Bitcoin consensus @@ -319,7 +328,7 @@ export default defineComponent({ const electrum = await getElectrumClient(); await electrum.waitForConsensusEstablished(); } - if (swapsUsdc && usePolygonNetworkStore().state.consensus !== 'established') { + if ((swapsUsdc || swapsUsdt) && usePolygonNetworkStore().state.consensus !== 'established') { await getPolygonClient(); } @@ -369,7 +378,7 @@ export default defineComponent({ const remoteFundingTx = await swapHandler.awaitIncoming(async (tx) => { if ('getBlock' in tx) { - // Unreachable, as UsdcAssetHandler does not fire onUpdate + // Unreachable, as Erc20AssetHandler does not fire onUpdate } else { updateSwap({ remoteFundingTx: tx, @@ -379,7 +388,10 @@ export default defineComponent({ if ('getBlock' in remoteFundingTx) { const receipt = await remoteFundingTx.getTransactionReceipt(); - const polygonTx = await receiptToTransaction(config.polygon.usdc.tokenContract, receipt); + const tokenAddress = activeSwap.value!.to.asset === SwapAsset.USDC_MATIC + ? config.polygon.usdc.tokenContract + : config.polygon.usdt_bridged.tokenContract; + const polygonTx = await receiptToTransaction(tokenAddress, receipt); updateSwap({ state: SwapState.CREATE_OUTGOING, stateEnteredAt: Date.now(), @@ -452,11 +464,11 @@ export default defineComponent({ } else if (activeSwap.value!.from.asset === SwapAsset.USDC_MATIC) { try { // Start background listener - usdcListener = usdcListener || swapHandler.awaitOutgoing((/* event */) => { + polygonListener = polygonListener || swapHandler.awaitOutgoing((/* event */) => { // const openEvent = event as PolygonEvent; // ... }).then((tx) => { - isUsdcListenerResolved = true; + isPolygonListenerResolved = true; currentError.value = null; return tx as PolygonEvent; }); @@ -473,8 +485,8 @@ export default defineComponent({ ); currentError.value = null; } catch (error) { - if (isUsdcListenerResolved) { - const event = await usdcListener; + if (isPolygonListenerResolved) { + const event = await polygonListener; fundingTx = await receiptToTransaction( config.polygon.usdc.tokenContract, await event.getTransactionReceipt(), @@ -488,7 +500,62 @@ export default defineComponent({ // We need to add it to the store ourselves. useUsdcTransactionsStore().addTransactions([fundingTx]); - await usdcListener; + await polygonListener; + + updateSwap({ + state: SwapState.AWAIT_SECRET, + stateEnteredAt: Date.now(), + fundingTx, + }); + } catch (error: any) { + if (error.message === SwapError.EXPIRED) return; + if (error.message === SwapError.DELETED) return; + + currentError.value = error.message; + setTimeout(processSwap, 2000); // 2 seconds + cleanUp(); + return; + } + } else if (activeSwap.value!.from.asset === SwapAsset.USDT_MATIC) { + try { + // Start background listener + polygonListener = polygonListener || swapHandler.awaitOutgoing((/* event */) => { + // const openEvent = event as PolygonEvent; + // ... + }).then((tx) => { + isPolygonListenerResolved = true; + currentError.value = null; + return tx as PolygonEvent; + }); + + const { request, signature, relayUrl } = JSON.parse(activeSwap.value.fundingSerializedTx!); + const { relayData, ...relayRequest } = request; + + let fundingTx: UsdtTransaction; + try { + fundingTx = await sendPolygonTransaction( + { request: relayRequest as ForwardRequest, relayData }, + signature, + relayUrl, + ); + currentError.value = null; + } catch (error) { + if (isPolygonListenerResolved) { + const event = await polygonListener; + fundingTx = await receiptToTransaction( + config.polygon.usdt_bridged.tokenContract, + await event.getTransactionReceipt(), + ); + } else { + throw error; + } + } + + // This is an outgoing transfer, so it won't be detected automatically. + // We need to add it to the store ourselves. + useUsdtTransactionsStore().addTransactions([fundingTx]); + + await polygonListener; updateSwap({ state: SwapState.AWAIT_SECRET, @@ -582,8 +649,8 @@ export default defineComponent({ if (activeSwap.value.to.asset === SwapAsset.USDC_MATIC) { try { // Start background listener - usdcListener = usdcListener || swapHandler.awaitIncomingConfirmation().then((tx) => { - isUsdcListenerResolved = true; + polygonListener = polygonListener || swapHandler.awaitIncomingConfirmation().then((tx) => { + isPolygonListenerResolved = true; currentError.value = null; return tx as PolygonEvent; }); @@ -605,8 +672,8 @@ export default defineComponent({ ); currentError.value = null; } catch (error) { - if (isUsdcListenerResolved) { - const event = await usdcListener; + if (isPolygonListenerResolved) { + const event = await polygonListener; settlementTx = await receiptToTransaction( config.polygon.usdc.tokenContract, await event.getTransactionReceipt(), @@ -616,7 +683,11 @@ export default defineComponent({ } } - await usdcListener; + await polygonListener; + + // This is an incoming transfer, so it should be detected automatically. + // But in my testing this did not work reliably, so we add it to the store ourselves. + useUsdcTransactionsStore().addTransactions([settlementTx]); updateSwap({ state: SwapState.COMPLETE, @@ -636,6 +707,67 @@ export default defineComponent({ cleanUp(); return; } + } else if (activeSwap.value.to.asset === SwapAsset.USDT_MATIC) { + try { + // Start background listener + polygonListener = polygonListener || swapHandler.awaitIncomingConfirmation().then((tx) => { + isPolygonListenerResolved = true; + currentError.value = null; + return tx as PolygonEvent; + }); + + const { + request, + signature, + relayUrl, + } = JSON.parse(activeSwap.value.settlementSerializedTx!); + const { relayData, ...relayRequest } = request; + + let settlementTx: UsdtTransaction; + try { + settlementTx = await sendPolygonTransaction( + { request: relayRequest as ForwardRequest, relayData }, + signature, + relayUrl, + `0x${activeSwap.value.secret}`, // <- Pass the secret as approvalData + ); + currentError.value = null; + } catch (error) { + if (isPolygonListenerResolved) { + const event = await polygonListener; + settlementTx = await receiptToTransaction( + config.polygon.usdt_bridged.tokenContract, + await event.getTransactionReceipt(), + ); + } else { + throw error; + } + } + + await polygonListener; + + // This is an incoming transfer, so it should be detected automatically. + // But in my testing this did not work reliably, so we add it to the store ourselves. + useUsdtTransactionsStore().addTransactions([settlementTx]); + + updateSwap({ + state: SwapState.COMPLETE, + stateEnteredAt: Date.now(), + settlementTx, + errorAction: undefined, + }); + } catch (error: any) { + if (error.message === SwapError.EXPIRED) return; + if (error.message === SwapError.DELETED) return; + + currentError.value = error.message; + updateSwap({ + errorAction: SwapErrorAction.USDT_RESIGN_REDEEM, + }); + setTimeout(processSwap, 2000); // 2 seconds + cleanUp(); + return; + } } else { try { const settlementTx = await swapHandler.settleIncoming( @@ -796,7 +928,7 @@ export default defineComponent({ SwapAsset.NIM, SwapAsset.BTC, SwapAsset.USDC_MATIC, - SwapAsset.USDT, + SwapAsset.USDT_MATIC, ]; const fiatCurrencies = [ diff --git a/src/components/swap/animation/usdt.svg b/src/components/swap/animation/usdt.svg new file mode 100644 index 000000000..309e9c0ff --- /dev/null +++ b/src/components/swap/animation/usdt.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/composables/useBtcTransactionInfo.ts b/src/composables/useBtcTransactionInfo.ts index fa9de914b..d031b8c2a 100644 --- a/src/composables/useBtcTransactionInfo.ts +++ b/src/composables/useBtcTransactionInfo.ts @@ -124,6 +124,10 @@ export function useBtcTransactionInfo(transaction: Ref) { return i18n.t('USD Coin') as string; } + if (swapData.value.asset === SwapAsset.USDT_MATIC) { + return i18n.t('Tether USD') as string; + } + if (swapData.value.asset === SwapAsset.EUR) { return swapData.value.bankLabel || i18n.t('Bank Account') as string; } diff --git a/src/composables/useSwapLimits.ts b/src/composables/useSwapLimits.ts index 60029e246..b0c92b004 100644 --- a/src/composables/useSwapLimits.ts +++ b/src/composables/useSwapLimits.ts @@ -11,6 +11,7 @@ import { useAddressStore } from '../stores/Address'; import { useBtcAddressStore } from '../stores/BtcAddress'; import { useKycStore } from '../stores/Kyc'; import { useUsdcTransactionsStore, Transaction as UsdcTransaction } from '../stores/UsdcTransactions'; +import { useUsdtTransactionsStore, Transaction as UsdtTransaction } from '../stores/UsdtTransactions'; import { usePolygonAddressStore } from '../stores/PolygonAddress'; const { activeCurrency } = useAccountStore(); @@ -29,6 +30,7 @@ const limits = ref(undefined); const nimAddress = ref(undefined); const btcAddress = ref(undefined); const usdcAddress = ref(undefined); +const usdtAddress = ref(undefined); const isFiatToCrypto = ref(false); const trigger = ref(0); @@ -66,10 +68,15 @@ watch(async () => { usdcAddress.value || '0x0000000000000000000000000000000000000000', // Burn address kycUser.value?.id, ); + const usdtAddressLimitsPromise = getLimits( + SwapAsset.USDT_MATIC, + usdtAddress.value || '0x0000000000000000000000000000000000000000', // Burn address + kycUser.value?.id, + ); const { accountAddresses } = useAddressStore(); const { activeAddresses } = useBtcAddressStore(); - const { addressInfo: usdcAddressInfo } = usePolygonAddressStore(); + const { addressInfo: polygonAddressInfo } = usePolygonAddressStore(); let newUserLimitEur = Infinity; @@ -116,7 +123,22 @@ watch(async () => { const usdcSwaps = Object.values(useUsdcTransactionsStore().state.transactions) .map((tx) => { // Ignore all transactions that are not on the current account - if (usdcAddressInfo.value?.address !== tx.recipient) return false; + if (polygonAddressInfo.value?.address !== tx.recipient) return false; + + const swap = getSwapByTransactionHash.value(tx.transactionHash); + // Ignore all swaps that are not from EUR + if (swap?.in?.asset !== SwapAsset.EUR) return false; + + return { + ...swap.in, + timestamp: tx.timestamp, + } as TimedSwap; + }) + .filter(Boolean) as TimedSwap[]; + const usdtSwaps = Object.values(useUsdtTransactionsStore().state.transactions) + .map((tx) => { + // Ignore all transactions that are not on the current account + if (polygonAddressInfo.value?.address !== tx.recipient) return false; const swap = getSwapByTransactionHash.value(tx.transactionHash); // Ignore all swaps that are not from EUR @@ -130,7 +152,7 @@ watch(async () => { .filter(Boolean) as TimedSwap[]; // Sort them chronologically - const swaps = [...nimSwaps, ...btcSwaps, ...usdcSwaps] + const swaps = [...nimSwaps, ...btcSwaps, ...usdcSwaps, ...usdtSwaps] .sort((a, b) => (a.timestamp || Infinity) - (b.timestamp || Infinity)); // Check if the first swap happened more than three days ago, otherwise calculate available limit @@ -201,12 +223,16 @@ watch(async () => { if ((tx.timestamp || Infinity) < cutOffTimestamp) return null; // Ignore all transactions that are not on the current account - if (![tx.sender, tx.recipient].includes(usdcAddressInfo.value?.address as string)) return null; + if (![tx.sender, tx.recipient].includes(polygonAddressInfo.value?.address as string)) return null; const swapHash = useSwapsStore().state.swapByTransaction[tx.transactionHash]; // Ignore all transactions that are not part of a swap or whose swap is already // part of the NIM or BTC transactions - if (!swapHash || nimSwapHashes.includes(swapHash) || btcSwapHashes.includes(swapHash)) return null; + if ( + !swapHash + || nimSwapHashes.includes(swapHash) + || btcSwapHashes.includes(swapHash) + ) return null; return { tx, @@ -217,10 +243,40 @@ watch(async () => { swapHash: string, })[]; + const usdcSwapHashes = swapUsdcTxs.map((obj) => obj.swapHash); + + // Find USDT tx that were involved in a swap, but not in a swap with NIM or BTC + const swapUsdtTxs = Object.values(useUsdtTransactionsStore().state.transactions).map((tx) => { + // Ignore all transactions before the cut-off + if ((tx.timestamp || Infinity) < cutOffTimestamp) return null; + + // Ignore all transactions that are not on the current account + if (![tx.sender, tx.recipient].includes(polygonAddressInfo.value?.address as string)) return null; + + const swapHash = useSwapsStore().state.swapByTransaction[tx.transactionHash]; + // Ignore all transactions that are not part of a swap or whose swap is already + // part of the NIM or BTC transactions + if ( + !swapHash + || nimSwapHashes.includes(swapHash) + || btcSwapHashes.includes(swapHash) + || usdcSwapHashes.includes(swapHash) + ) return null; + + return { + tx, + swapHash, + }; + }).filter(Boolean) as ({ + tx: UsdtTransaction, + swapHash: string, + })[]; + // Find historic tx values in USD await useTransactionsStore().calculateFiatAmounts(swapNimTxs.map(({ tx }) => tx), FiatCurrency.USD); await useBtcTransactionsStore().calculateFiatAmounts(swapBtcTxs.map(({ tx }) => tx), FiatCurrency.USD); await useUsdcTransactionsStore().calculateFiatAmounts(swapUsdcTxs.map(({ tx }) => tx), FiatCurrency.USD); + await useUsdtTransactionsStore().calculateFiatAmounts(swapUsdtTxs.map(({ tx }) => tx), FiatCurrency.USD); const swappedAmount = swapNimTxs.reduce((sum, obj) => { const usdValue = obj.tx.timestamp @@ -246,16 +302,25 @@ watch(async () => { : (useFiatStore().state.exchangeRates[CryptoCurrency.USDC][FiatCurrency.USD] || 0) * (obj.tx.value / 1e6); return sum + usdValue; + }, 0) + + swapUsdtTxs.reduce((sum, obj) => { + const usdValue = obj.tx.timestamp + ? obj.tx.fiatValue ? obj.tx.fiatValue[FiatCurrency.USD] || 0 : 0 + : (useFiatStore().state.exchangeRates[CryptoCurrency.USDT][FiatCurrency.USD] || 0) + * (obj.tx.value / 1e6); + return sum + usdValue; }, 0); const userLimits = await userLimitsPromise; const nimAddressLimits = await nimAddressLimitsPromise; const btcAddressLimits = await btcAddressLimitsPromise; const usdcAddressLimits = await usdcAddressLimitsPromise; + const usdtAddressLimits = await usdtAddressLimitsPromise; const lunaRate = (nimAddressLimits.reference.monthly / 100) / nimAddressLimits.monthly; const satRate = (btcAddressLimits.reference.monthly / 100) / btcAddressLimits.monthly; const centRate = (usdcAddressLimits.reference.monthly / 100) / usdcAddressLimits.monthly; + // const centRate = (usdtAddressLimits.reference.monthly / 100) / usdtAddressLimits.monthly; const monthlyUsdLimit = (userLimits || nimAddressLimits.reference).monthly / 100; @@ -265,6 +330,7 @@ watch(async () => { nimAddress.value ? (nimAddressLimits.reference.current / 100) : Infinity, btcAddress.value ? (btcAddressLimits.reference.current / 100) : Infinity, usdcAddress.value ? (usdcAddressLimits.reference.current / 100) : Infinity, + usdtAddress.value ? (usdtAddressLimits.reference.current / 100) : Infinity, )); const remainingUsdLimits = Math.max(0, Math.min( @@ -273,6 +339,7 @@ watch(async () => { nimAddress.value ? (nimAddressLimits.reference.monthlyRemaining / 100) : Infinity, btcAddress.value ? (btcAddressLimits.reference.monthlyRemaining / 100) : Infinity, usdcAddress.value ? (usdcAddressLimits.reference.monthlyRemaining / 100) : Infinity, + usdtAddress.value ? (usdtAddressLimits.reference.monthlyRemaining / 100) : Infinity, )); limits.value = { @@ -322,11 +389,13 @@ export function useSwapLimits(options: { nimAddress?: string, btcAddress?: string, usdcAddress?: string, + usdtAddress?: string, isFiatToCrypto?: boolean, }) { nimAddress.value = options.nimAddress; btcAddress.value = options.btcAddress; usdcAddress.value = options.usdcAddress; + usdtAddress.value = options.usdtAddress; isFiatToCrypto.value = options.isFiatToCrypto || false; recalculate(); @@ -335,6 +404,7 @@ export function useSwapLimits(options: { nimAddress, btcAddress, usdcAddress, + usdtAddress, recalculate, }; } diff --git a/src/composables/useTransactionInfo.ts b/src/composables/useTransactionInfo.ts index dc1eae89e..5f4db3c2e 100644 --- a/src/composables/useTransactionInfo.ts +++ b/src/composables/useTransactionInfo.ts @@ -137,6 +137,10 @@ export function useTransactionInfo(transaction: Ref) { return i18n.t('USD Coin') as string; } + if (swapData.value.asset === SwapAsset.USDT_MATIC) { + return i18n.t('Tether USD') as string; + } + if (swapData.value.asset === SwapAsset.EUR) { return swapData.value.bankLabel || i18n.t('Bank Account') as string; } diff --git a/src/composables/useUsdcTransactionInfo.ts b/src/composables/useUsdcTransactionInfo.ts index 8c11dcbc7..f2a790dde 100644 --- a/src/composables/useUsdcTransactionInfo.ts +++ b/src/composables/useUsdcTransactionInfo.ts @@ -130,8 +130,8 @@ export function useUsdcTransactionInfo(transaction: Ref) { const data = computed(() => { // eslint-disable-line arrow-body-style if (swapData.value && !isCancelledSwap.value) { const message = i18n.t('Sent {fromAsset} – Received {toAsset}', { - fromAsset: isIncoming.value ? assetToCurrency(swapData.value.asset).toUpperCase() : SwapAsset.USDC, - toAsset: isIncoming.value ? SwapAsset.USDC : assetToCurrency(swapData.value.asset).toUpperCase(), + fromAsset: isIncoming.value ? assetToCurrency(swapData.value.asset).toUpperCase() : 'USDC', + toAsset: isIncoming.value ? 'USDC' : assetToCurrency(swapData.value.asset).toUpperCase(), }) as string; // The TransactionListOasisPayoutStatus takes care of the second half of the message diff --git a/src/composables/useUsdtTransactionInfo.ts b/src/composables/useUsdtTransactionInfo.ts index 37e820084..2ae24b4ca 100644 --- a/src/composables/useUsdtTransactionInfo.ts +++ b/src/composables/useUsdtTransactionInfo.ts @@ -124,21 +124,25 @@ export function useUsdtTransactionInfo(transaction: Ref) { // Data const data = computed(() => { // eslint-disable-line arrow-body-style - // if (swapData.value && !isCancelledSwap.value) { - // const message = i18n.t('Sent {fromAsset} – Received {toAsset}', { - // fromAsset: isIncoming.value ? assetToCurrency(swapData.value.asset).toUpperCase() : SwapAsset.USDT, - // toAsset: isIncoming.value ? SwapAsset.USDT : assetToCurrency(swapData.value.asset).toUpperCase(), - // }) as string; - - // // The TransactionListOasisPayoutStatus takes care of the second half of the message - // if ( - // swapData.value.asset === SwapAsset.EUR - // && swapData.value.htlc?.settlement - // && swapData.value.htlc.settlement.status !== SettlementStatus.CONFIRMED - // ) return `${message.split('–')[0]} –`; - - // return message; - // } + if (swapData.value && !isCancelledSwap.value) { + const message = i18n.t('Sent {fromAsset} – Received {toAsset}', { + fromAsset: isIncoming.value + ? assetToCurrency(swapData.value.asset).toUpperCase() + : 'USDT', + toAsset: isIncoming.value + ? 'USDT' + : assetToCurrency(swapData.value.asset).toUpperCase(), + }) as string; + + // The TransactionListOasisPayoutStatus takes care of the second half of the message + if ( + swapData.value.asset === SwapAsset.EUR + && swapData.value.htlc?.settlement + && swapData.value.htlc.settlement.status !== SettlementStatus.CONFIRMED + ) return `${message.split('–')[0]} –`; + + return message; + } if (transaction.value.event?.name === 'Open') { return i18n.t('HTLC Creation') as string; diff --git a/src/ethers.ts b/src/ethers.ts index 9aedf85df..69bb58780 100644 --- a/src/ethers.ts +++ b/src/ethers.ts @@ -1361,6 +1361,7 @@ type ContractMethods = | 'transferWithApproval' | 'open' | 'openWithPermit' + | 'openWithApproval' | 'redeemWithSecretInData' | 'refund' // | 'swap' @@ -1388,6 +1389,7 @@ export async function calculateFee( transferWithApproval: 1220, open: 1220, openWithPermit: 1348, // TODO: Recheck this value + openWithApproval: 1348, // TODO: Recheck this value redeemWithSecretInData: 1092, refund: 1092, // swap: 0, diff --git a/src/lib/ExplorerUtils.ts b/src/lib/ExplorerUtils.ts index 5bc4c53c7..5c1ca0e7f 100644 --- a/src/lib/ExplorerUtils.ts +++ b/src/lib/ExplorerUtils.ts @@ -27,7 +27,7 @@ export function explorerAddrLink(asset: SwapAsset, address: string) { + `/address/${address}`; case SwapAsset.USDC: case SwapAsset.USDC_MATIC: - // case SwapAsset.USDT: + case SwapAsset.USDT_MATIC: return `https://${config.environment === ENV_MAIN ? '' : 'amoy.'}polygonscan.com/address/${address}`; case SwapAsset.EUR: if (config.environment === ENV_MAIN) return `https://oasis.watch/?id=${address}`; diff --git a/src/lib/swap/utils/Assets.ts b/src/lib/swap/utils/Assets.ts index dd3a2c617..914319d7d 100644 --- a/src/lib/swap/utils/Assets.ts +++ b/src/lib/swap/utils/Assets.ts @@ -6,7 +6,7 @@ export type SupportedSwapAsset = | SwapAsset.BTC | SwapAsset.USDC | SwapAsset.USDC_MATIC - | SwapAsset.USDT + | SwapAsset.USDT_MATIC | SwapAsset.EUR; export function assetToCurrency(asset: Exclude): CryptoCurrency; @@ -18,7 +18,7 @@ export function assetToCurrency(asset: SupportedSwapAsset): CryptoCurrency | Fia [SwapAsset.BTC]: CryptoCurrency.BTC, [SwapAsset.USDC]: CryptoCurrency.USDC, [SwapAsset.USDC_MATIC]: CryptoCurrency.USDC, - [SwapAsset.USDT]: CryptoCurrency.USDT, + [SwapAsset.USDT_MATIC]: CryptoCurrency.USDT, [SwapAsset.EUR]: FiatCurrency.EUR, ['CRC']: FiatCurrency.CRC, // eslint-disable-line no-useless-computed-key }[asset]; diff --git a/src/lib/swap/utils/Functions.ts b/src/lib/swap/utils/Functions.ts index 9402bbd39..52df9a554 100644 --- a/src/lib/swap/utils/Functions.ts +++ b/src/lib/swap/utils/Functions.ts @@ -30,7 +30,7 @@ export type SettlementFees = { } export function getEurPerCrypto( - asset: SwapAsset.NIM | SwapAsset.BTC | SwapAsset.USDC | SwapAsset.USDC_MATIC | SwapAsset.USDT, + asset: SwapAsset.NIM | SwapAsset.BTC | SwapAsset.USDC | SwapAsset.USDC_MATIC | SwapAsset.USDT_MATIC, estimate: Estimate, ) { let coinFactor: number; @@ -39,7 +39,7 @@ export function getEurPerCrypto( case SwapAsset.BTC: coinFactor = 1e8; break; case SwapAsset.USDC: coinFactor = 1e6; break; case SwapAsset.USDC_MATIC: coinFactor = 1e6; break; - case SwapAsset.USDT: coinFactor = 1e6; break; + case SwapAsset.USDT_MATIC: coinFactor = 1e6; break; } if (estimate.from.asset === asset) { diff --git a/src/stores/Swaps.ts b/src/stores/Swaps.ts index be8cc95f7..da153425e 100644 --- a/src/stores/Swaps.ts +++ b/src/stores/Swaps.ts @@ -48,7 +48,7 @@ export type SwapBtcData = { }; export type SwapErc20Data = { - asset: SwapAsset.USDC | SwapAsset.USDC_MATIC | SwapAsset.USDT, + asset: SwapAsset.USDC | SwapAsset.USDC_MATIC | SwapAsset.USDT_MATIC, transactionHash: string, htlc?: { address?: string, diff --git a/src/stores/UsdtTransactions.ts b/src/stores/UsdtTransactions.ts index 5c0cd99ed..6f1b4b006 100644 --- a/src/stores/UsdtTransactions.ts +++ b/src/stores/UsdtTransactions.ts @@ -1,6 +1,6 @@ import Vue from 'vue'; import { getHistoricExchangeRates, isHistorySupportedFiatCurrency } from '@nimiq/utils'; -// import { SwapAsset } from '@nimiq/fastspot-api'; +import { SwapAsset } from '@nimiq/fastspot-api'; import { createStore } from 'pinia'; import { useFiatStore } from './Fiat'; import { CryptoCurrency, FiatCurrency, FIAT_API_PROVIDER_TX_HISTORY, FIAT_PRICE_UNAVAILABLE } from '../lib/Constants'; @@ -228,63 +228,63 @@ export const useUsdtTransactionsStore = createStore({ // Note: this method should not modify the transaction itself or the transaction store, to avoid race conditions with // other methods that do so. async function detectSwap(transaction: Transaction, knownTransactions: Transaction[]) { - // const { state: swaps$, addFundingData, addSettlementData, detectSwapFiatCounterpart } = useSwapsStore(); - // if (swaps$.swapByTransaction[transaction.transactionHash]) return; // already known - // const asset = SwapAsset.USDT; - - // // HTLC Creation - // if (transaction.event?.name === 'Open') { - // const hashRoot = transaction.event.hash.substring(2); - // addFundingData(hashRoot, { - // asset, - // transactionHash: transaction.transactionHash, - // htlc: { - // address: transaction.event.id, - // refundAddress: transaction.sender, - // redeemAddress: transaction.event.recipient, - // timeoutTimestamp: transaction.event.timeout, - // }, - // }); - - // await detectSwapFiatCounterpart(transaction.event.id.substring(2), hashRoot, 'settlement', asset); - // } - - // // HTLC Refunding - // if (transaction.event?.name === 'Refund') { - // const swapId = transaction.event.id; - // // Find funding transaction - // const selector = (testedTx: Transaction) => - // testedTx.event?.name === 'Open' && testedTx.event.id === swapId; - - // // First search known transactions - // const fundingTx = knownTransactions.find(selector); - - // // Then get funding transaction from the blockchain - // if (!fundingTx) { - // // TODO: Find Open event for transaction.event!.id - // // const client = await getPolygonClient(); - // // const chainTxs = await client.getTransactionsByAddress(transaction.sender); - // // fundingTx = chainTxs.map((transaction) => transaction.toPlain()).find(selector); - // } - - // if (fundingTx) { - // const hashRoot = (fundingTx.event as HtlcOpenEvent).hash.substring(2); - // addSettlementData(hashRoot, { - // asset, - // transactionHash: transaction.transactionHash, - // }); - // } - // } - - // // HTLC Settlement - // if (transaction.event?.name === 'Redeem') { - // const secret = transaction.event.secret.substring(2); - // const hashRoot = Nimiq.Hash.sha256(Nimiq.BufferUtils.fromHex(secret)).toHex(); - // addSettlementData(hashRoot, { - // asset, - // transactionHash: transaction.transactionHash, - // }); - - // await detectSwapFiatCounterpart(transaction.event.id.substring(2), hashRoot, 'funding', asset); - // } + const { state: swaps$, addFundingData, addSettlementData, detectSwapFiatCounterpart } = useSwapsStore(); + if (swaps$.swapByTransaction[transaction.transactionHash]) return; // already known + const asset = SwapAsset.USDT_MATIC; + + // HTLC Creation + if (transaction.event?.name === 'Open') { + const hashRoot = transaction.event.hash.substring(2); + addFundingData(hashRoot, { + asset, + transactionHash: transaction.transactionHash, + htlc: { + address: transaction.event.id, + refundAddress: transaction.sender, + redeemAddress: transaction.event.recipient, + timeoutTimestamp: transaction.event.timeout, + }, + }); + + await detectSwapFiatCounterpart(transaction.event.id.substring(2), hashRoot, 'settlement', asset); + } + + // HTLC Refunding + if (transaction.event?.name === 'Refund') { + const swapId = transaction.event.id; + // Find funding transaction + const selector = (testedTx: Transaction) => + testedTx.event?.name === 'Open' && testedTx.event.id === swapId; + + // First search known transactions + const fundingTx = knownTransactions.find(selector); + + // Then get funding transaction from the blockchain + if (!fundingTx) { + // TODO: Find Open event for transaction.event!.id + // const client = await getPolygonClient(); + // const chainTxs = await client.getTransactionsByAddress(transaction.sender); + // fundingTx = chainTxs.map((transaction) => transaction.toPlain()).find(selector); + } + + if (fundingTx) { + const hashRoot = (fundingTx.event as HtlcOpenEvent).hash.substring(2); + addSettlementData(hashRoot, { + asset, + transactionHash: transaction.transactionHash, + }); + } + } + + // HTLC Settlement + if (transaction.event?.name === 'Redeem') { + const secret = transaction.event.secret.substring(2); + const hashRoot = Nimiq.Hash.sha256(Nimiq.BufferUtils.fromHex(secret)).toHex(); + addSettlementData(hashRoot, { + asset, + transactionHash: transaction.transactionHash, + }); + + await detectSwapFiatCounterpart(transaction.event.id.substring(2), hashRoot, 'funding', asset); + } } diff --git a/yarn.lock b/yarn.lock index 96b136697..66cd45967 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1491,29 +1491,30 @@ resolved "https://registry.yarnpkg.com/@nimiq/core-web/-/core-web-1.6.1.tgz#97cb5b43b257c7f6f6808ef603e9bf686377241f" integrity sha512-WYw2brIxUXa/SQ0JRp0RXWQKzBFhROXrEjF9Eh+tRlC+NrI2ObwRQkwJCbP2qmPtYldIimfyECmsDVHFoyLXjQ== +"@nimiq/electrum-client@git+https://github.com/nimiq/electrum-client.git#build": + version "0.2.2" + uid "4f706a0acb13b8e8abdf5be68cfc036eb102e3fc" + resolved "git+https://github.com/nimiq/electrum-client.git#4f706a0acb13b8e8abdf5be68cfc036eb102e3fc" + dependencies: + bitcoinjs-lib "^5.1.10" + "@nimiq/electrum-client@https://github.com/nimiq/electrum-client#build": version "0.2.2" resolved "https://github.com/nimiq/electrum-client#4f706a0acb13b8e8abdf5be68cfc036eb102e3fc" dependencies: bitcoinjs-lib "^5.1.10" -"@nimiq/fastspot-api@^1.10.0": - version "1.10.0" - resolved "https://registry.yarnpkg.com/@nimiq/fastspot-api/-/fastspot-api-1.10.0.tgz#b5df2a748d6ca9de1565e764e2de80568b8d1a08" - integrity sha512-Hl9xNkpAqxgVJWKAts91/wpdl5pltb8I/wN88BNRH4wuwv6sY7sZa6nWWvL7nFrUqtwJJ9XDaHsimYnDoYMyhw== - -"@nimiq/fastspot-api@^1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@nimiq/fastspot-api/-/fastspot-api-1.8.0.tgz#705a9e79e425c3e6536d8994fd0b39d88af1b268" - integrity sha512-qNkibJnxS8ndOn4tuy1m3lSNKybBYApo+wy1ajTKcQ0lHo3VfLY0sAJ+WRE7diVWCa7iumu6wsFVudyc3k8/NQ== +"@nimiq/fastspot-api@^1.10.2": + version "1.10.2" + resolved "https://registry.yarnpkg.com/@nimiq/fastspot-api/-/fastspot-api-1.10.2.tgz#ae2cbe5b41359875bece9ce0957209ca1b486d21" + integrity sha512-rPy3DhWlqTOj4k9/YMS2mizjR1rLsQzqWzMnJ+xDEGprgb6/jhDBZs/CW+jONccRQWrvuyBYTrrxdLgn3imKjQ== -"@nimiq/hub-api@^1.8.0": +"@nimiq/hub-api@https://gitpkg.now.sh/nimiq/hub/client?73c31757aa9e93912f7540a272914ecdb51a23ad": version "1.8.0" - resolved "https://registry.yarnpkg.com/@nimiq/hub-api/-/hub-api-1.8.0.tgz#750c7a0cf6cf2ca06527303cb83088e268842c89" - integrity sha512-AVK2U1Fm36+gpD9CSfDZkg987BbVfFcsOMm9XQZ8sM3nG+fPcAqfSr7mUMFt8pU+7apo19MP57h+P+nycGeHIA== + resolved "https://gitpkg.now.sh/nimiq/hub/client?73c31757aa9e93912f7540a272914ecdb51a23ad#d49a63372ce17760fbc37781102db6300c0e832d" dependencies: "@nimiq/core-web" "^1.6.1" - "@nimiq/fastspot-api" "^1.8.0" + "@nimiq/fastspot-api" "^1.10.2" "@nimiq/rpc" "^0.4.0" "@nimiq/utils" "^0.5.0" "@opengsn/common" "^2.2.5" @@ -1526,10 +1527,10 @@ dependencies: dom-parser "^0.1.5" -"@nimiq/libswap@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@nimiq/libswap/-/libswap-1.4.0.tgz#fd58060755ec4ff99f9add961b78d853561e47fa" - integrity sha512-YjJZ4Imy/35Gz/tAruklDnsawFVCs33GcPB6Ta81mZqUqi/mamw76Bs4NSNF3xrbKgkxfBIPaIvWd2jqJMKS3w== +"@nimiq/libswap@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@nimiq/libswap/-/libswap-1.4.1.tgz#f24aa54be6a20d12a20206b1c9585a32bb4cab17" + integrity sha512-wWffPQCJQLGYamu1RTO1LwX5XjYjFHOc1C3KttXzFUSJvMlAjU+9/H6cBKycqnbHyO6OSN2R+bBLWKIcpS6ccA== dependencies: "@nimiq/core-web" "^1.5.8" "@nimiq/electrum-client" "https://github.com/nimiq/electrum-client#build"