From 89c1b492348cf4b24c61f22260d7a7f89db50e5a Mon Sep 17 00:00:00 2001 From: Derek Guenther Date: Mon, 30 Sep 2024 17:49:17 -0400 Subject: [PATCH] Use Iron Fish Bridge API (#256) --- .../handleGetChainportTransactionStatus.ts | 20 -- main/api/chainport/index.ts | 199 ++++++++---------- .../chainport/utils/decodeChainportMemo.ts | 33 --- .../chainport/utils/getChainportEndpoints.ts | 34 --- main/api/chainport/vendor/README.md | 3 + main/api/chainport/vendor/config.ts | 41 ++++ main/api/chainport/vendor/metadata.ts | 90 ++++++++ main/api/chainport/vendor/requests.ts | 93 ++++++++ main/api/chainport/vendor/types.ts | 62 ++++++ main/api/chainport/vendor/utils.ts | 96 +++++++++ main/api/transactions/handleGetTransaction.ts | 8 + .../handleGetTransactionsForContact.ts | 5 +- .../utils/formatTransactionsToNotes.ts | 13 +- .../BridgeAssetsForm/BridgeAssetsForm.tsx | 143 ++++++++----- .../BridgeConfirmationModal.tsx | 20 +- .../BridgeAssetsForm/bridgeAssetsSchema.ts | 15 +- .../BridgeTransactionInformation.tsx | 82 ++++---- .../BridgeTransactionInformationShell.tsx | 14 +- .../components/CopyAddress/CopyAddress.tsx | 11 +- renderer/components/NotesList/NotesList.tsx | 5 +- .../pages/accounts/[account-name]/index.tsx | 5 +- .../transaction/[transaction-hash].tsx | 17 +- .../pages/address-book/[address]/index.tsx | 5 +- .../useChainportTransactionStatus.ts | 6 +- shared/chainport.ts | 187 +++++----------- shared/isChainportTx.ts | 92 -------- shared/types.ts | 3 + 27 files changed, 721 insertions(+), 581 deletions(-) delete mode 100644 main/api/chainport/handleGetChainportTransactionStatus.ts delete mode 100644 main/api/chainport/utils/decodeChainportMemo.ts delete mode 100644 main/api/chainport/utils/getChainportEndpoints.ts create mode 100644 main/api/chainport/vendor/README.md create mode 100644 main/api/chainport/vendor/config.ts create mode 100644 main/api/chainport/vendor/metadata.ts create mode 100644 main/api/chainport/vendor/requests.ts create mode 100644 main/api/chainport/vendor/types.ts create mode 100644 main/api/chainport/vendor/utils.ts delete mode 100644 shared/isChainportTx.ts diff --git a/main/api/chainport/handleGetChainportTransactionStatus.ts b/main/api/chainport/handleGetChainportTransactionStatus.ts deleted file mode 100644 index 00aeddd4..00000000 --- a/main/api/chainport/handleGetChainportTransactionStatus.ts +++ /dev/null @@ -1,20 +0,0 @@ -import axios from "axios"; - -import { getChainportEndpoints } from "./utils/getChainportEndpoints"; -import { ChainportTransactionStatus } from "../../../shared/chainport"; - -export async function handleGetChainportTransactionStatus({ - txHash, - baseNetworkId, -}: { - txHash: string; - baseNetworkId: number; -}) { - const endpoints = await getChainportEndpoints(); - const url = `${endpoints.baseUrl}/api/port?base_tx_hash=${txHash}&base_network_id=${baseNetworkId}`; - - const response = await axios(url); - const data = response.data as ChainportTransactionStatus; - - return data; -} diff --git a/main/api/chainport/index.ts b/main/api/chainport/index.ts index bbc0f1f1..414b7651 100644 --- a/main/api/chainport/index.ts +++ b/main/api/chainport/index.ts @@ -1,87 +1,43 @@ -import axios from "axios"; import { z } from "zod"; import { handleGetChainportBridgeTransactionEstimatedFees } from "./handleGetChainportBridgeTransactionEstimatedFees"; -import { handleGetChainportTransactionStatus } from "./handleGetChainportTransactionStatus"; import { handleSendChainportBridgeTransaction, handleSendChainportBridgeTransactionInput, } from "./handleSendChainportBridgeTransaction"; import { buildTransactionRequestParamsInputs } from "./utils/buildTransactionRequestParams"; -import { decodeChainportMemo } from "./utils/decodeChainportMemo"; -import { getChainportEndpoints } from "./utils/getChainportEndpoints"; import { - ChainportBridgeTransaction, - ChainportTargetNetwork, - ChainportToken, - assertMetadataApiResponse, + fetchChainportBridgeTransaction, + fetchChainportNetworks, + fetchChainportTokenPaths, + fetchChainportTokens, + fetchChainportTransactionStatus, +} from "./vendor/requests"; +import { ChainportNetwork } from "./vendor/types"; +import { + assertTokenPathsApiResponse, assertTokensApiResponse, } from "../../../shared/chainport"; import { logger } from "../ironfish/logger"; +import { manager } from "../manager"; import { t } from "../trpc"; export const chainportRouter = t.router({ getChainportTokens: t.procedure.query(async () => { - const { tokensEndpoint, metadataEndpoint } = await getChainportEndpoints(); + const ironfish = await manager.getIronfish(); + const rpcClient = await ironfish.rpcClient(); + const network = await rpcClient.chain.getNetworkInfo(); try { - const [tokensResponse, metadataResponse] = await Promise.all([ - axios.get(tokensEndpoint), - axios.get(metadataEndpoint), - ]); - - const tokensData = assertTokensApiResponse(tokensResponse.data); - const chainportMeta = assertMetadataApiResponse(metadataResponse.data); - - const chainportTokens = tokensData.verified_tokens.map((token) => { - const targetNetworks: Array = - token.target_networks - .map((networkId) => { - const networkDetails = chainportMeta.cp_network_ids[networkId]; - if (!networkDetails) { - throw new Error(`Unknown network id: ${networkId}`); - } - return { - chainportNetworkId: networkId, - label: networkDetails.label, - networkIcon: networkDetails.network_icon, - chainId: networkDetails.chain_id, - value: networkDetails.chain_id - ? networkDetails.chain_id.toString() - : "", - }; - }) - .filter((item) => { - return item.value !== null; - }); - - return { - chainportId: token.id, - ironfishId: token.web3_address, - symbol: token.symbol, - name: token.name, - decimals: token.decimals, - targetNetworks, - }; - }); - - const tokenEntries = chainportTokens.map<[string, ChainportToken]>( - (token) => [token.ironfishId, token], + const tokensResponse = await fetchChainportTokens( + network.content.networkId, ); - const chainportTokensMap: Record = - Object.fromEntries(tokenEntries); + const tokensData = assertTokensApiResponse(tokensResponse); - const networksEntries = chainportTokens.flatMap((token) => - token.targetNetworks.map<[string, ChainportTargetNetwork]>( - (network) => [network.value, network], - ), - ); - const chainportNetworksMap: Record = - Object.fromEntries(networksEntries); return { - chainportTokens, - chainportTokensMap, - chainportNetworksMap, + chainportTokensMap: Object.fromEntries( + tokensData.map((token) => [token.web3_address, token]), + ), }; } catch (err) { logger.error(`Failed to fetch Chainport tokens data. @@ -91,46 +47,68 @@ ${err} throw err; } }), + getChainportTokenPaths: t.procedure + .input( + z.object({ + tokenId: z.number(), + }), + ) + .query( + async (opts): Promise<{ chainportTokenPaths: ChainportNetwork[] }> => { + const ironfish = await manager.getIronfish(); + const rpcClient = await ironfish.rpcClient(); + const network = await rpcClient.chain.getNetworkInfo(); + + try { + const tokenPathsResponse = await fetchChainportTokenPaths( + network.content.networkId, + opts.input.tokenId, + ); + const tokenPathsData = + assertTokenPathsApiResponse(tokenPathsResponse); + + return { + chainportTokenPaths: tokenPathsData, + }; + } catch (err) { + logger.error(`Failed to fetch Chainport token paths data. + +${err} +`); + throw err; + } + }, + ), getChainportBridgeTransactionDetails: t.procedure .input( z.object({ amount: z.string(), assetId: z.string(), to: z.string(), - selectedNetwork: z.string(), + selectedNetwork: z.number(), }), ) .query(async (opts) => { - const endpoints = await getChainportEndpoints(); + const ironfish = await manager.getIronfish(); + const rpcClient = await ironfish.rpcClient(); + const network = await rpcClient.chain.getNetworkInfo(); const { amount, assetId, to, selectedNetwork } = opts.input; + try { + return await fetchChainportBridgeTransaction( + network.content.networkId, + BigInt(amount), + assetId, + selectedNetwork, + to, + ); + } catch (err) { + logger.error(`Failed to fetch Chainport bridge transaction details. - const url = `${ - endpoints.baseUrl - }/ironfish/metadata?raw_amount=${amount.toString()}&asset_id=${assetId}&target_network_id=${selectedNetwork}&target_web3_address=${to}`; - - const response = await fetch(url); - const data: - | ChainportBridgeTransaction - | { - error: { - code: string; - description: string; - }; - } = await response.json(); - - if ("error" in data) { - throw new Error(data.error.description); - } - - if (!response.ok) { - logger.error(`Failed to fetch chainport bridge transaction details. - -${data}`); - throw new Error("A network error occured, please try again"); +${err} +`); + throw err; } - - return data; }), getChainportBridgeTransactionEstimatedFees: t.procedure .input(buildTransactionRequestParamsInputs) @@ -150,36 +128,23 @@ ${data}`); .input( z.object({ transactionHash: z.string(), - baseNetworkId: z.number(), }), ) .query(async (opts) => { - const result = handleGetChainportTransactionStatus({ - txHash: opts.input.transactionHash, - baseNetworkId: opts.input.baseNetworkId, - }); - return result; + const ironfish = await manager.getIronfish(); + const rpcClient = await ironfish.rpcClient(); + const network = await rpcClient.chain.getNetworkInfo(); + + return await fetchChainportTransactionStatus( + network.content.networkId, + opts.input.transactionHash, + ); }), - getChainportMeta: t.procedure.query(async () => { - const { metadataEndpoint } = await getChainportEndpoints(); - const response = await axios.get(metadataEndpoint); - const data = assertMetadataApiResponse(response.data); - return data; + getChainportNetworks: t.procedure.query(async () => { + const ironfish = await manager.getIronfish(); + const rpcClient = await ironfish.rpcClient(); + const network = await rpcClient.chain.getNetworkInfo(); + + return await fetchChainportNetworks(network.content.networkId); }), - decodeMemo: t.procedure - .input( - z.object({ - memo: z.string(), - }), - ) - .query(async (opts) => { - try { - const result = decodeChainportMemo( - Buffer.from(opts.input.memo).toString(), - ); - return result; - } catch (_err) { - return null; - } - }), }); diff --git a/main/api/chainport/utils/decodeChainportMemo.ts b/main/api/chainport/utils/decodeChainportMemo.ts deleted file mode 100644 index f10e3985..00000000 --- a/main/api/chainport/utils/decodeChainportMemo.ts +++ /dev/null @@ -1,33 +0,0 @@ -function decodeNumberFrom10Bits(bits: string) { - return parseInt("0" + bits.slice(1, 10), 2); -} - -function decodeCharFrom6Bits(bits: string) { - const num = parseInt(bits, 2); - if (num < 10) { - return num.toString(); - } - return String.fromCharCode(num - 10 + "a".charCodeAt(0)); -} - -export function decodeChainportMemo( - encodedHex: string, -): [number, string, boolean] { - const hexInteger = BigInt("0x" + encodedHex); - const encodedString = hexInteger.toString(2); - const padded = encodedString.padStart(250, "0"); - const networkId = decodeNumberFrom10Bits(padded); - - const toIronfish = padded[0] === "1"; - const addressCharacters = []; - - for (let i = 10; i < padded.length; i += 6) { - const j = i + 6; - const charBits = padded.slice(i, j); - addressCharacters.push(decodeCharFrom6Bits(charBits)); - } - - const address = "0x" + addressCharacters.join(""); - - return [networkId, address.toLowerCase(), toIronfish]; -} diff --git a/main/api/chainport/utils/getChainportEndpoints.ts b/main/api/chainport/utils/getChainportEndpoints.ts deleted file mode 100644 index d80574c6..00000000 --- a/main/api/chainport/utils/getChainportEndpoints.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { MAINNET } from "@ironfish/sdk"; - -import { manager } from "../../manager"; - -export async function getChainportEndpoints() { - const ironfish = await manager.getIronfish(); - const rpcClient = await ironfish.rpcClient(); - const response = await rpcClient.chain.getNetworkInfo(); - - const networkId = response.content.networkId.toString(); - - const prefix = { - "0": "preprod-", - "1": "", - }[networkId]; - - if (typeof prefix !== "string") { - throw new Error( - `Iron Fish node is currently using an unknown network id: ${response.content.networkId}`, - ); - } - - const baseUrl = `https://${prefix}api.chainport.io`; - - // TODO: Remove this once the Mainnet API is updated - const tokensEndpoint = - networkId === MAINNET.id.toString() - ? `${baseUrl}/token/list?network_name=IRONFISH` - : `${baseUrl}/token_list?network_name=IRONFISH`; - console.log("tokensEndpoint", tokensEndpoint); - const metadataEndpoint = `${baseUrl}/meta`; - - return { baseUrl, tokensEndpoint, metadataEndpoint }; -} diff --git a/main/api/chainport/vendor/README.md b/main/api/chainport/vendor/README.md new file mode 100644 index 00000000..cb67fb39 --- /dev/null +++ b/main/api/chainport/vendor/README.md @@ -0,0 +1,3 @@ +### Note + +This code is shared with [the Iron Fish CLI](https://github.com/iron-fish/ironfish/tree/master/ironfish-cli/src/utils/chainport). Please propagate changes to that repository as well. \ No newline at end of file diff --git a/main/api/chainport/vendor/config.ts b/main/api/chainport/vendor/config.ts new file mode 100644 index 00000000..de16839e --- /dev/null +++ b/main/api/chainport/vendor/config.ts @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { MAINNET, TESTNET } from "@ironfish/sdk"; + +const config = { + [TESTNET.id]: { + endpoint: "https://testnet.api.ironfish.network", + outgoingAddresses: new Set([ + "06102d319ab7e77b914a1bd135577f3e266fd82a3e537a02db281421ed8b3d13", + "db2cf6ec67addde84cc1092378ea22e7bb2eecdeecac5e43febc1cb8fb64b5e5", + "3be494deb669ff8d943463bb6042eabcf0c5346cf444d569e07204487716cb85", + ]), + incomingAddresses: new Set([ + "06102d319ab7e77b914a1bd135577f3e266fd82a3e537a02db281421ed8b3d13", + ]), + }, + [MAINNET.id]: { + endpoint: "https://api.ironfish.network", + outgoingAddresses: new Set([ + "576ffdcc27e11d81f5180d3dc5690294941170d492b2d9503c39130b1f180405", + "7ac2d6a59e19e66e590d014af013cd5611dc146e631fa2aedf0ee3ed1237eebe", + ]), + incomingAddresses: new Set([ + "1216302193e8f1ad020f458b54a163039403d803e98673c6a85e59b5f4a1a900", + ]), + }, +}; + +export const isNetworkSupportedByChainport = (networkId: number) => { + return !!config[networkId]; +}; + +export const getConfig = (networkId: number) => { + if (!config[networkId]) { + throw new Error(`Unsupported network ${networkId} for chainport`); + } + + return config[networkId]; +}; diff --git a/main/api/chainport/vendor/metadata.ts b/main/api/chainport/vendor/metadata.ts new file mode 100644 index 00000000..a92030d9 --- /dev/null +++ b/main/api/chainport/vendor/metadata.ts @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * Chainport memo metadata encoding and decoding + * The metadata is encoded in a 64 character hex string + * The first bit is a flag to indicate if the transaction is to IronFish or from IronFish + * The next 10 bits are the network id + * The rest of the bits are the address + * + * Official documentation: https://docs.chainport.io/for-developers/integrate-chainport/iron-fish/utilities/ironfishmetadata + */ +export class ChainportMemoMetadata { + constructor() {} + + public static convertNumberToBinaryString(num: number, padding: number) { + return num.toString(2).padStart(padding, "0"); + } + + public static encodeNumberTo10Bits(number: number) { + return this.convertNumberToBinaryString(number, 10); + } + + public static decodeNumberFrom10Bits(bits: string) { + return parseInt("0" + bits.slice(1, 10), 2); + } + + public static encodeCharacterTo6Bits(character: string) { + const parsedInt = parseInt(character); + if (!isNaN(parsedInt)) { + return this.convertNumberToBinaryString(parsedInt, 6); + } + + const int = character.charCodeAt(0) - "a".charCodeAt(0) + 10; + return this.convertNumberToBinaryString(int, 6); + } + + public static decodeCharFrom6Bits(bits: string) { + const num = parseInt(bits, 2); + if (num < 10) { + return num.toString(); + } + return String.fromCharCode(num - 10 + "a".charCodeAt(0)); + } + + public static encode( + networkId: number, + address: string, + toIronfish: boolean, + ) { + if (address.startsWith("0x")) { + address = address.slice(2); + } + + const encodedNetworkId = this.encodeNumberTo10Bits(networkId); + const encodedAddress = address + .toLowerCase() + .split("") + .map((character: string) => { + return this.encodeCharacterTo6Bits(character); + }) + .join(""); + + const combined = + (toIronfish ? "1" : "0") + (encodedNetworkId + encodedAddress).slice(1); + const hexString = BigInt("0b" + combined).toString(16); + return hexString.padStart(64, "0"); + } + + public static decode(encodedHex: string): [number, string, boolean] { + const hexInteger = BigInt("0x" + encodedHex); + const encodedString = hexInteger.toString(2); + const padded = encodedString.padStart(250, "0"); + const networkId = this.decodeNumberFrom10Bits(padded); + + const toIronfish = padded[0] === "1"; + const addressCharacters = []; + + for (let i = 10; i < padded.length; i += 6) { + const j = i + 6; + const charBits = padded.slice(i, j); + addressCharacters.push(this.decodeCharFrom6Bits(charBits)); + } + + const address = "0x" + addressCharacters.join(""); + + return [networkId, address.toLowerCase(), toIronfish]; + } +} diff --git a/main/api/chainport/vendor/requests.ts b/main/api/chainport/vendor/requests.ts new file mode 100644 index 00000000..c5c05297 --- /dev/null +++ b/main/api/chainport/vendor/requests.ts @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import axios from "axios"; + +import { getConfig } from "./config"; +import { + ChainportBridgeTransaction, + ChainportNetwork, + ChainportToken, + ChainportTransactionStatus, +} from "./types"; + +// Wrappers around chainport API requests. Documentation here: https://docs.chainport.io/for-developers/integrate-chainport/iron-fish/iron-fish-to-evm + +export const fetchChainportTransactionStatus = async ( + networkId: number, + hash: string, +): Promise => { + const config = getConfig(networkId); + const url = new URL(`/bridges/transactions/status`, config.endpoint); + url.searchParams.append("hash", hash); + + return await makeChainportRequest(url.toString()); +}; + +export const fetchChainportNetworks = async ( + networkId: number, +): Promise => { + const config = getConfig(networkId); + const url = new URL("/bridges/networks", config.endpoint).toString(); + + return (await makeChainportRequest<{ data: ChainportNetwork[] }>(url)).data; +}; + +export const fetchChainportTokens = async ( + networkId: number, +): Promise => { + const config = getConfig(networkId); + const url = new URL("/bridges/tokens", config.endpoint).toString(); + + return (await makeChainportRequest<{ data: ChainportToken[] }>(url)).data; +}; + +export const fetchChainportTokenPaths = async ( + networkId: number, + tokenId: number, +): Promise => { + const config = getConfig(networkId); + const url = new URL( + `/bridges/tokens/${tokenId}/networks`, + config.endpoint, + ).toString(); + return (await makeChainportRequest<{ data: ChainportNetwork[] }>(url)).data; +}; + +export const fetchChainportBridgeTransaction = async ( + networkId: number, + amount: bigint, + assetId: string, + targetNetworkId: number, + targetAddress: string, +): Promise => { + const config = getConfig(networkId); + const url = new URL(`/bridges/transactions/create`, config.endpoint); + url.searchParams.append("amount", amount.toString()); + url.searchParams.append("asset_id", assetId); + url.searchParams.append("target_network_id", targetNetworkId.toString()); + url.searchParams.append("target_address", targetAddress.toString()); + + return await makeChainportRequest(url.toString()); +}; + +const makeChainportRequest = async ( + url: string, +): Promise => { + const response = await axios + .get(url) + .then((response) => { + return response.data; + }) + .catch((error) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const chainportError = error.response?.data?.error?.description as string; + if (chainportError) { + throw new Error(chainportError); + } else { + throw new Error("Chainport error - " + error); + } + }); + + return response; +}; diff --git a/main/api/chainport/vendor/types.ts b/main/api/chainport/vendor/types.ts new file mode 100644 index 00000000..607510ae --- /dev/null +++ b/main/api/chainport/vendor/types.ts @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +// This file contains response types for chainport requests + +export type ChainportBridgeTransaction = { + bridge_output: { + publicAddress: string; + amount: string; + memoHex: string; + assetId: string; + }; + gas_fee_output: { + publicAddress: string; + amount: string; + memo: string; + }; + bridge_fee: { + source_token_fee_amount: string; + portx_fee_amount: string; + is_portx_fee_payment: boolean; + }; +}; + +export type ChainportNetwork = { + chainport_network_id: number; + explorer_url: string; + label: string; + network_icon: string; +}; + +export type ChainportToken = { + id: number; + decimals: number; + name: string; + pinned: boolean; + web3_address: string; + symbol: string; + token_image: string; + chain_id: number | null; + network_name: string; + network_id: number; + blockchain_type: string; + is_stable: boolean; + is_lifi: boolean; +}; + +export type ChainportTransactionStatus = + | Record // empty object + | { + base_network_id: number | null; + base_tx_hash: string | null; + base_tx_status: number | null; + base_token_address: string | null; + target_network_id: number | null; + target_tx_hash: string | null; + target_tx_status: number | null; + target_token_address: string | null; + created_at: string | null; + port_in_ack: boolean | null; + }; diff --git a/main/api/chainport/vendor/utils.ts b/main/api/chainport/vendor/utils.ts new file mode 100644 index 00000000..5b70eec5 --- /dev/null +++ b/main/api/chainport/vendor/utils.ts @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { RpcWalletTransaction, TransactionType } from "@ironfish/sdk"; + +import { getConfig, isNetworkSupportedByChainport } from "./config"; +import { ChainportMemoMetadata } from "./metadata"; + +export type ChainportTransactionData = + | { + type: TransactionType.SEND | TransactionType.RECEIVE; + chainportNetworkId: number; + address: string; + } + | undefined; + +export const extractChainportDataFromTransaction = ( + networkId: number, + transaction: RpcWalletTransaction, +): ChainportTransactionData => { + if (isNetworkSupportedByChainport(networkId) === false) { + return undefined; + } + + const config = getConfig(networkId); + + if (transaction.type === TransactionType.SEND) { + return getOutgoingChainportTransactionData(transaction, config); + } + if (transaction.type === TransactionType.RECEIVE) { + return getIncomingChainportTransactionData(transaction, config); + } + return undefined; +}; + +const getIncomingChainportTransactionData = ( + transaction: RpcWalletTransaction, + config: { incomingAddresses: Set }, +): ChainportTransactionData => { + const bridgeNote = transaction.notes?.[0]; + + if ( + !bridgeNote || + !isAddressInSet(bridgeNote.sender, config.incomingAddresses) + ) { + return undefined; + } + + const [sourceNetwork, address, _] = ChainportMemoMetadata.decode( + bridgeNote.memoHex, + ); + + return { + type: TransactionType.RECEIVE, + chainportNetworkId: sourceNetwork, + address: address, + }; +}; + +const getOutgoingChainportTransactionData = ( + transaction: RpcWalletTransaction, + config: { outgoingAddresses: Set }, +): ChainportTransactionData => { + if (!transaction.notes || transaction.notes.length < 2) { + return undefined; + } + + if ( + !transaction.notes.find((note) => note.memo === '{"type": "fee_payment"}') + ) { + return undefined; + } + + const bridgeNote = transaction.notes.find((note) => + isAddressInSet(note.owner, config.outgoingAddresses), + ); + + if (!bridgeNote) { + return undefined; + } + + const [sourceNetwork, address, _] = ChainportMemoMetadata.decode( + bridgeNote.memoHex, + ); + + return { + type: TransactionType.SEND, + chainportNetworkId: sourceNetwork, + address: address, + }; +}; + +const isAddressInSet = (address: string, addressSet: Set): boolean => { + return addressSet.has(address.toLowerCase()); +}; diff --git a/main/api/transactions/handleGetTransaction.ts b/main/api/transactions/handleGetTransaction.ts index abe48129..e692eee0 100644 --- a/main/api/transactions/handleGetTransaction.ts +++ b/main/api/transactions/handleGetTransaction.ts @@ -1,4 +1,5 @@ import { getTransactionNotes } from "./utils/formatTransactionsToNotes"; +import { extractChainportDataFromTransaction } from "../chainport/vendor/utils"; import { manager } from "../manager"; export async function handleGetTransaction({ @@ -23,6 +24,12 @@ export async function handleGetTransaction({ ); } + const network = (await rpcClient.chain.getNetworkInfo()).content.networkId; + const chainportData = extractChainportDataFromTransaction( + network, + content.transaction, + ); + const notes = await getTransactionNotes( rpcClient, content.transaction, @@ -32,5 +39,6 @@ export async function handleGetTransaction({ return { transaction: content.transaction, notes, + chainportData, }; } diff --git a/main/api/transactions/handleGetTransactionsForContact.ts b/main/api/transactions/handleGetTransactionsForContact.ts index ab84d18f..c163a3f5 100644 --- a/main/api/transactions/handleGetTransactionsForContact.ts +++ b/main/api/transactions/handleGetTransactionsForContact.ts @@ -12,6 +12,8 @@ export async function handleGetTransactionsForContact({ const ironfish = await manager.getIronfish(); const rpcClient = await ironfish.rpcClient(); + const networkId = (await rpcClient.chain.getNetworkInfo()).content.networkId; + const accountsResponse = await rpcClient.wallet.getAccounts(); const accountNames = accountsResponse.content.accounts; @@ -37,9 +39,6 @@ export async function handleGetTransactionsForContact({ } } - const networkId = (await rpcClient.chain.getNetworkInfo()).content - .networkId; - const notes = await formatTransactionsToNotes( rpcClient, transactions, diff --git a/main/api/transactions/utils/formatTransactionsToNotes.ts b/main/api/transactions/utils/formatTransactionsToNotes.ts index 9af12ec3..21147ece 100644 --- a/main/api/transactions/utils/formatTransactionsToNotes.ts +++ b/main/api/transactions/utils/formatTransactionsToNotes.ts @@ -2,9 +2,10 @@ import { RpcAsset, RpcClient, RpcWalletTransaction } from "@ironfish/sdk"; import log from "electron-log"; import { IRON_ID } from "@shared/constants"; -import { hasChainportOutgoingAddress } from "@shared/isChainportTx"; import { TransactionNote } from "@shared/types"; +import { extractChainportDataFromTransaction } from "../../chainport/vendor/utils"; + export async function createAssetLookup( client: RpcClient, assetIds: string[], @@ -50,6 +51,7 @@ export async function getTransactionNotes( memo: note.memo, noteHash: note.noteHash, accountName, + chainportData: undefined, }; }) ?? []; @@ -80,6 +82,8 @@ export async function formatTransactionsToNotes( continue; } + const chainportData = extractChainportDataFromTransaction(networkId, tx); + const firstNote = tx.notes[0]; // True if any asset balance is non-zero, ignoring the transaction fee (if current account is sender) @@ -97,11 +101,7 @@ export async function formatTransactionsToNotes( // This is because the fee payment is paid in $IRON, so the transaction will have two deltas, // one for the custom asset being bridged, and one for the $IRON fee payment. const shouldSkipChainportFeeNote = - tx.type === "send" && - tx.assetBalanceDeltas.length > 1 && - tx.notes?.some((note) => { - return hasChainportOutgoingAddress(networkId, note.owner); - }); + tx.type === "send" && tx.assetBalanceDeltas.length > 1 && !!chainportData; // Make a TransactionNote for each asset balance delta for (const abd of tx.assetBalanceDeltas) { @@ -173,6 +173,7 @@ export async function formatTransactionsToNotes( value: absoluteDeltaWithoutFee, noteHash: "", memo: memo, + chainportData, }); } } diff --git a/renderer/components/BridgeAssetsForm/BridgeAssetsForm.tsx b/renderer/components/BridgeAssetsForm/BridgeAssetsForm.tsx index ecdcaa96..b8f32901 100644 --- a/renderer/components/BridgeAssetsForm/BridgeAssetsForm.tsx +++ b/renderer/components/BridgeAssetsForm/BridgeAssetsForm.tsx @@ -21,7 +21,11 @@ import { TextInput } from "@/ui/Forms/TextInput/TextInput"; import { getChecksumAddress, isAddress } from "@/utils/ethereumAddressUtils"; import { BridgeAssetsFormShell } from "./BridgeAssetsFormShell"; -import { BridgeAssetsFormData, bridgeAssetsSchema } from "./bridgeAssetsSchema"; +import { + BridgeAssetsConfirmationData, + BridgeAssetsFormData, + bridgeAssetsFormSchema, +} from "./bridgeAssetsSchema"; import { BridgeConfirmationModal } from "./BridgeConfirmationModal/BridgeConfirmationModal"; import { AssetAmountInput } from "../AssetAmountInput/AssetAmountInput"; import { @@ -49,22 +53,19 @@ const messages = defineMessages({ }); type ChainportToken = - TRPCRouterOutputs["getChainportTokens"]["chainportTokens"][number]; -type ChainportTargetNetwork = ChainportToken["targetNetworks"][number]; + TRPCRouterOutputs["getChainportTokens"]["chainportTokensMap"][string]; function BridgeAssetsFormContent({ accountsData, chainportTokensMap, - chainportTargetNetworksMap, }: { accountsData: TRPCRouterOutputs["getAccounts"]; chainportTokensMap: Record; - chainportTargetNetworksMap: Record; }) { const { formatMessage } = useIntl(); const [confirmationData, setConfirmationData] = - useState(null); + useState(null); const [transactionDetailsError, setTransactionDetailsError] = useState(""); const accountOptions = useMemo(() => { @@ -79,13 +80,7 @@ function BridgeAssetsFormContent({ const defaultFromAccount = accountOptions[0]?.value; const defaultAssetId = accountsData[0]?.balances.iron.asset.id || - Object.values(chainportTokensMap)[0]?.ironfishId; - const defaultDestinationNetwork = - chainportTokensMap[defaultAssetId]?.targetNetworks[0].value.toString(); - - if (!defaultDestinationNetwork) { - console.error("No default destination network found."); - } + Object.values(chainportTokensMap)[0]?.web3_address; const { register, @@ -96,14 +91,14 @@ function BridgeAssetsFormContent({ clearErrors, control, formState: { errors: formErrors }, - } = useForm({ - resolver: zodResolver(bridgeAssetsSchema), + } = useForm({ + resolver: zodResolver(bridgeAssetsFormSchema), mode: "onBlur", defaultValues: { amount: "0", fromAccount: defaultFromAccount, assetId: defaultAssetId, - destinationNetwork: defaultDestinationNetwork, + destinationNetworkId: null, targetAddress: "", }, }); @@ -111,21 +106,19 @@ function BridgeAssetsFormContent({ const amountValue = watch("amount"); const fromAccountValue = watch("fromAccount"); const assetIdValue = watch("assetId"); - const destinationNetworkValue = watch("destinationNetwork"); + const destinationNetworkId = watch("destinationNetworkId"); const targetAddress = watch("targetAddress"); - // If the user selects a different asset, and that asset does not support the selected network, - // then we automatically switch to the first available network for that asset. - useEffect(() => { - const availableNetworks = chainportTokensMap[assetIdValue]?.targetNetworks; - const selectedNetwork = availableNetworks?.find( - (network) => network.chainId?.toString() === destinationNetworkValue, + const { data: tokenPathsResponse } = + trpcReact.getChainportTokenPaths.useQuery( + { + tokenId: chainportTokensMap[assetIdValue]?.id, + }, + { + enabled: !!chainportTokensMap[assetIdValue], + }, ); - - if (availableNetworks && !selectedNetwork) { - setValue("destinationNetwork", availableNetworks[0].value.toString()); - } - }, [assetIdValue, chainportTokensMap, destinationNetworkValue, setValue]); + const availableNetworks = tokenPathsResponse?.chainportTokenPaths; const selectedAccount = useMemo(() => { return ( @@ -138,16 +131,9 @@ function BridgeAssetsFormContent({ balanceInLabel: false, }); - const currentNetwork = chainportTargetNetworksMap[destinationNetworkValue]; - const bridgeableAssets = useMemo(() => { const withAdditionalFields = assetOptions .map((item) => { - const isBridgableForNetwork = chainportTokensMap[ - item.asset.id - ]?.targetNetworks.some( - (network) => network.chainId === currentNetwork?.chainId, - ); return { ...item, label: ( @@ -158,7 +144,7 @@ function BridgeAssetsFormContent({ )} ), - disabled: !isBridgableForNetwork, + disabled: !chainportTokensMap[item.asset.id], }; }) .toSorted((a, b) => { @@ -168,15 +154,12 @@ function BridgeAssetsFormContent({ }); return withAdditionalFields; - }, [chainportTokensMap, assetOptions, currentNetwork]); - - const availableNetworks = chainportTokensMap[assetIdValue]?.targetNetworks; - - if (!availableNetworks) { - console.error("No available networks found"); - } + }, [assetOptions, chainportTokensMap]); const selectedAsset = assetOptionsMap.get(assetIdValue); + const selectedNetwork = availableNetworks?.find( + (n) => destinationNetworkId === n.chainport_network_id.toString(), + ); const handleIfAmountExceedsBalance = useCallback( (amount: string) => { @@ -212,12 +195,59 @@ function BridgeAssetsFormContent({ ) : null; }, [targetAddress]); + // Try to reset selected asset to a valid one if the current one is disabled + useEffect(() => { + const selectedAsset = bridgeableAssets.find( + (asset) => asset.value === assetIdValue, + ); + if (!selectedAsset || selectedAsset.disabled) { + const bridgeableAsset = + bridgeableAssets.find((asset) => !asset.disabled) ?? + bridgeableAssets[0]; + setValue("assetId", bridgeableAsset?.value ?? defaultAssetId); + } + }, [ + assetIdValue, + setValue, + bridgeableAssets, + chainportTokensMap, + defaultAssetId, + ]); + + // Clear destination network if the selected asset changes + // Might be better to wait and retain the network if it exists in the new set of token paths + useEffect(() => { + setValue("destinationNetworkId", null); + }, [assetIdValue, setValue]); + + // Switch to the first available network for the selected asset + useEffect(() => { + if ( + availableNetworks && + availableNetworks.length > 0 && + destinationNetworkId === null + ) { + setValue( + "destinationNetworkId", + availableNetworks[0].chainport_network_id.toString(), + ); + } + }, [availableNetworks, destinationNetworkId, setValue]); + return ( <> { setTransactionDetailsError(""); + const destinationNetwork = availableNetworks?.find( + (n) => + data.destinationNetworkId === n.chainport_network_id.toString(), + ); + if (!destinationNetwork) { + return; + } + if (handleIfAmountExceedsBalance(data.amount)) { return; } @@ -226,7 +256,7 @@ function BridgeAssetsFormContent({ amount: data.amount, fromAccount: data.fromAccount, assetId: data.assetId, - destinationNetwork: data.destinationNetwork, + destinationNetwork: destinationNetwork, targetAddress: getChecksumAddress(data.targetAddress), }); })} @@ -334,16 +364,22 @@ function BridgeAssetsFormContent({ } destinationNetworkInput={