From fb9466a4d7827fd4759c726ad3ae0b43dddcacd3 Mon Sep 17 00:00:00 2001 From: hzheng-ledger <71653044+hzheng-ledger@users.noreply.github.com> Date: Thu, 29 Aug 2024 08:04:55 +0200 Subject: [PATCH] Feat/ton jetton integration (#7672) * feat(ton): Jetton integration * feat(ton): Adjust integration test * feat(ton): Add changeset * feat(ton): add bot tests for jettons and clean code * feat(ton): adjust comment input width and error field name * feat(ton): fix a test * feat(ton): move a function and adjust a value * feat(ton): adjust test * fix: integration test * fix: ton integration test --------- Co-authored-by: Maria Ayelen Murano --- .changeset/fair-berries-drum.md | 23 ++ .../renderer/families/ton/CommentField.tsx | 2 +- .../families/ton/SendRecipientFields.tsx | 12 +- .../static/i18n/en/app.json | 9 + .../src/locales/en/common.json | 9 + libs/coin-modules/coin-ton/package.json | 2 + .../src/__tests__/fixtures/api.fixtures.ts | 7 + .../src/__tests__/fixtures/common.fixtures.ts | 53 ++++- .../integration/bridge.integration.test.ts | 81 ++++++- .../src/__tests__/unit/api.unit.test.ts | 14 ++ .../unit/deviceTransactionConfig.unit.test.ts | 37 ++- .../unit/estimateMaxSpendable.unit.test.ts | 19 +- .../unit/getTransactionStatus.unit.test.ts | 37 ++- .../src/__tests__/unit/logic.unit.test.ts | 16 ++ .../unit/prepareTransaction.unit.test.ts | 33 +++ .../src/__tests__/unit/txn.unit.test.ts | 87 ++++++- .../src/__tests__/unit/utils.unit.test.ts | 108 ++++++++- .../coin-ton/src/bridge/bridgeHelpers/api.ts | 35 +++ .../src/bridge/bridgeHelpers/api.types.ts | 34 +++ .../coin-ton/src/bridge/bridgeHelpers/txn.ts | 147 +++++++++++- .../coin-ton/src/cli-transaction.ts | 10 +- libs/coin-modules/coin-ton/src/constants.ts | 36 +++ .../coin-ton/src/deviceTransactionConfig.ts | 33 ++- libs/coin-modules/coin-ton/src/errors.ts | 17 ++ .../coin-ton/src/estimateMaxSpendable.ts | 30 ++- .../coin-ton/src/getTransactionStatus.ts | 42 +++- libs/coin-modules/coin-ton/src/logic.ts | 18 ++ .../coin-ton/src/prepareTransaction.ts | 6 +- .../coin-ton/src/signOperation.ts | 43 +++- libs/coin-modules/coin-ton/src/specs.ts | 61 ++++- .../coin-ton/src/speculos-deviceActions.ts | 108 ++++++--- .../coin-ton/src/synchronisation.ts | 154 +++++++++++-- libs/coin-modules/coin-ton/src/utils.ts | 217 ++++++++++++++++-- .../sortByMarketcap.test.ts.snap | 47 ++++ .../bridge.integration.test.ts.snap | 90 ++++++++ .../cryptoassets/src/data/jetton.json | 49 ++++ .../packages/cryptoassets/src/data/jetton.ts | 12 + .../packages/cryptoassets/src/tokens.ts | 36 +++ .../ui/packages/crypto-icons/src/svg/BURN.svg | 3 + libs/ui/packages/crypto-icons/src/svg/FNZ.svg | 3 + .../packages/crypto-icons/src/svg/GEMSTON.svg | 3 + .../packages/crypto-icons/src/svg/GLINT.svg | 3 + .../ui/packages/crypto-icons/src/svg/GRAM.svg | 3 + .../packages/crypto-icons/src/svg/HTON1.svg | 3 + .../packages/crypto-icons/src/svg/JETTON.svg | 4 + .../ui/packages/crypto-icons/src/svg/JMNT.svg | 18 ++ libs/ui/packages/crypto-icons/src/svg/JVT.svg | 5 + .../ui/packages/crypto-icons/src/svg/LAVE.svg | 5 + libs/ui/packages/crypto-icons/src/svg/MEH.svg | 5 + libs/ui/packages/crypto-icons/src/svg/MEM.svg | 3 + libs/ui/packages/crypto-icons/src/svg/NOT.svg | 3 + .../packages/crypto-icons/src/svg/PLANE.svg | 3 + .../packages/crypto-icons/src/svg/PROTON.svg | 5 + .../ui/packages/crypto-icons/src/svg/PUNK.svg | 4 + .../ui/packages/crypto-icons/src/svg/RAFF.svg | 5 + .../packages/crypto-icons/src/svg/SCALE.svg | 5 + libs/ui/packages/crypto-icons/src/svg/SOX.svg | 6 + .../ui/packages/crypto-icons/src/svg/STON.svg | 6 + .../packages/crypto-icons/src/svg/TONNEL.svg | 6 + libs/ui/packages/crypto-icons/src/svg/UP.svg | 3 + .../ui/packages/crypto-icons/src/svg/WEB3.svg | 3 + .../packages/crypto-icons/src/svg/jUSDC.svg | 3 + .../packages/crypto-icons/src/svg/jUSDT.svg | 3 + .../packages/crypto-icons/src/svg/jWBTC.svg | 5 + .../packages/crypto-icons/src/svg/stTON.svg | 5 + .../packages/crypto-icons/src/svg/tGRAM.svg | 3 + pnpm-lock.yaml | 6 + 67 files changed, 1785 insertions(+), 121 deletions(-) create mode 100644 .changeset/fair-berries-drum.md create mode 100644 libs/coin-modules/coin-ton/src/__tests__/unit/logic.unit.test.ts create mode 100644 libs/coin-modules/coin-ton/src/constants.ts create mode 100644 libs/coin-modules/coin-ton/src/logic.ts create mode 100644 libs/ledgerjs/packages/cryptoassets/src/data/jetton.json create mode 100644 libs/ledgerjs/packages/cryptoassets/src/data/jetton.ts create mode 100644 libs/ui/packages/crypto-icons/src/svg/BURN.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/FNZ.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/GEMSTON.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/GLINT.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/GRAM.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/HTON1.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/JETTON.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/JMNT.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/JVT.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/LAVE.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/MEH.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/MEM.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/NOT.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/PLANE.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/PROTON.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/PUNK.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/RAFF.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/SCALE.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/SOX.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/STON.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/TONNEL.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/UP.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/WEB3.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/jUSDC.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/jUSDT.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/jWBTC.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/stTON.svg create mode 100644 libs/ui/packages/crypto-icons/src/svg/tGRAM.svg diff --git a/.changeset/fair-berries-drum.md b/.changeset/fair-berries-drum.md new file mode 100644 index 000000000000..bafff622a79b --- /dev/null +++ b/.changeset/fair-berries-drum.md @@ -0,0 +1,23 @@ +--- +"@actions/live-common-affected": patch +"@ledgerhq/types-live": patch +"@ledgerhq/errors": patch +"@ledgerhq/live-countervalues-react": patch +"@ledgerhq/crypto-icons-ui": patch +"@actions/turbo-affected": patch +"@ledgerhq/coin-icon": patch +"@ledgerhq/webpack.js-example": patch +"@ledgerhq/coin-ton": patch +"@actions/build-checks": patch +"ledger-live-desktop": patch +"@ledgerhq/next.js-example": patch +"live-mobile": patch +"@ledgerhq/live-common": patch +"@ledgerhq/live-countervalues": patch +"@ledgerhq/speculos-transport": patch +"@ledgerhq/native-ui": patch +"@ledgerhq/icons-ui": patch +"@ledgerhq/react-ui": patch +--- + +Add support for jettons diff --git a/apps/ledger-live-desktop/src/renderer/families/ton/CommentField.tsx b/apps/ledger-live-desktop/src/renderer/families/ton/CommentField.tsx index 154434ffa97e..09a302497386 100644 --- a/apps/ledger-live-desktop/src/renderer/families/ton/CommentField.tsx +++ b/apps/ledger-live-desktop/src/renderer/families/ton/CommentField.tsx @@ -38,7 +38,7 @@ const CommentField = ({ // on the ledger-live mobile return ( - - + + diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json index e856b1a184f2..8efc606b68aa 100644 --- a/apps/ledger-live-desktop/static/i18n/en/app.json +++ b/apps/ledger-live-desktop/static/i18n/en/app.json @@ -6213,6 +6213,15 @@ }, "BitcoinInfrastructureError": { "title": "We are experiencing an issue with our Bitcoin infrastructure. Please try again later." + }, + "TonExcessFee": { + "title": "It constitutes a token transfer. You will pay 0.1 TON in fees, and any extra will be returned to you." + }, + "TonNotEnoughBalanceInParentAccount": { + "title": "Insufficient funds in main account (TON) to calculate fees. The minimum required balance is 0.1 TON." + }, + "TonMinimumRequired": { + "title": "Insufficient funds. The minimum required balance is 0.02 TON." } }, "cryptoOrg": { diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index fecfc7b7c453..ce63fc761d40 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -949,6 +949,15 @@ "TrustchainNotFound": { "title": "Something went wrong while fetching your data", "description": "Please try again or contact Ledger Support." + }, + "TonExcessFee": { + "title": "It constitutes a token transfer. You will pay 0.1 TON in fees, and any extra will be returned to you." + }, + "TonNotEnoughBalanceInParentAccount": { + "title": "Insufficient funds in main account (TON) to calculate fees. The minimum required balance is 0.1 TON." + }, + "TonMinimumRequired": { + "title": "Insufficient funds. The minimum required balance is 0.02 TON." } }, "crash": { diff --git a/libs/coin-modules/coin-ton/package.json b/libs/coin-modules/coin-ton/package.json index 891457ffd5bd..5b198a1b7724 100644 --- a/libs/coin-modules/coin-ton/package.json +++ b/libs/coin-modules/coin-ton/package.json @@ -58,12 +58,14 @@ "@ton/crypto": "^3.3.0", "bignumber.js": "^9.1.2", "expect": "^27.4.6", + "imurmurhash": "^0.1.4", "invariant": "^2.2.2", "lodash": "^4.17.21", "msw": "^2.0.11", "rxjs": "^7.8.1" }, "devDependencies": { + "@types/imurmurhash": "^0.1.4", "@types/invariant": "^2.2.2", "@types/jest": "^29.5.10", "@types/lodash": "^4.14.191", diff --git a/libs/coin-modules/coin-ton/src/__tests__/fixtures/api.fixtures.ts b/libs/coin-modules/coin-ton/src/__tests__/fixtures/api.fixtures.ts index 296b0a347be8..36e7a76bd2b0 100644 --- a/libs/coin-modules/coin-ton/src/__tests__/fixtures/api.fixtures.ts +++ b/libs/coin-modules/coin-ton/src/__tests__/fixtures/api.fixtures.ts @@ -1,6 +1,8 @@ import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { + jettonTransferResponse, + jettonWallets, lastBlockNumber, tonAccount, tonEstimateFee, @@ -29,6 +31,11 @@ const handlers = [ http.get(`${API_TON_ENDPOINT}/wallet`, () => { return HttpResponse.json(tonWallet); }), + // Handle GET request for jetton transfers endpoint + http.get(`${API_TON_ENDPOINT}/jetton/transfers`, () => HttpResponse.json(jettonTransferResponse)), + // Handle GET request for jetton wallets endpoint + http.get(`${API_TON_ENDPOINT}/jetton/wallets`, () => HttpResponse.json(jettonWallets)), + // Handle POST request for estimate fee endpoint http.post(`${API_TON_ENDPOINT}/estimateFee`, () => HttpResponse.json(tonEstimateFee)), ]; diff --git a/libs/coin-modules/coin-ton/src/__tests__/fixtures/common.fixtures.ts b/libs/coin-modules/coin-ton/src/__tests__/fixtures/common.fixtures.ts index 4a1a08715e5b..bc1a60517d1a 100644 --- a/libs/coin-modules/coin-ton/src/__tests__/fixtures/common.fixtures.ts +++ b/libs/coin-modules/coin-ton/src/__tests__/fixtures/common.fixtures.ts @@ -1,9 +1,11 @@ import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; -import { Account } from "@ledgerhq/types-live"; +import { Account, TokenAccount } from "@ledgerhq/types-live"; import BigNumber from "bignumber.js"; import { TonAccountInfo, TonResponseEstimateFee, + TonResponseJettonTransfer, + TonResponseJettonWallets, TonResponseWalletInfo, TonTransactionsList, } from "../../bridge/bridgeHelpers/api.types"; @@ -13,6 +15,15 @@ export const mockAddress = "UQDzd8aeBOU-jqYw_ZSuZjceI5p-F4b7HMprAsUJAtRPbMol"; export const mockAccountId = "js:2:ton:b19891a06654f21c64147550b3321bef63acd25b5dd61b688b022c42fac4831d:ton"; +export const tokenAccount = { + id: "subAccountId", + type: "TokenAccount", + spendableBalance: new BigNumber("5000000"), + token: { + contractAddress: "0:A2CC9B938389950125001F6B8AF280CACA23BE045714AD69387DD546588D667E", + }, +} as TokenAccount; + export const account = { id: mockAccountId, freshAddress: mockAddress, @@ -23,6 +34,7 @@ export const account = { spendableBalance: new BigNumber("1000000000"), balance: new BigNumber("1000000000"), seedIdentifier: "seedIdentifier", + subAccounts: [tokenAccount], } as Account; export const transaction = { @@ -35,6 +47,11 @@ export const transaction = { family: "ton", } as unknown as Transaction; +export const jettonTransaction = { + ...transaction, + subAccountId: "subAccountId", +} as Transaction; + export const fees = { in_fwd_fee: 10000, storage_fee: 10000, @@ -73,11 +90,45 @@ export const tonWallet: TonResponseWalletInfo = { status: "active", }; +export const jettonWallets: TonResponseJettonWallets = { + jetton_wallets: [ + { + address: "0:495AB6C978E3C0AE7FCF863A2D4504E37CE8D2D04A5E59048301BA29EC372F79", + balance: "1200000000000", + owner: "0:D02D314791CB10EF3F964CC7421E4F46348C262444946F7A64C2374700E3ED19", + jetton: "0:3C52A0A732A83F022E517E5C2715E0EE458A4B9772580E903FF491526C3E9137", + last_transaction_lt: "30345242000008", + code_hash: "3axDia4eCUnTVixqU0/BUA4i8id5BtVw1pt/yayZd6k=", + data_hash: "P8j0kENM5s4zE2w5IpD8NrrSneGQ7d0mzs5yTBNPlqo=", + }, + ], +}; + export const tonEstimateFee: TonResponseEstimateFee = { source_fees: fees, destination_fees: [], }; +export const jettonTransferResponse: TonResponseJettonTransfer = { + jetton_transfers: [ + { + query_id: "1", + source: "UQDnqcVSV4S9m2Y9gLAQrDerQktKSx2I1uhs6r5o_H8VT4x7", + destination: mockAddress, + amount: "", + source_wallet: "", + jetton_master: "0:729C13B6DF2C07CBF0A06AB63D34AF454F3D320EC1BCD8FB5C6D24D0806A17C2", + transaction_hash: "", + transaction_lt: "", + transaction_now: 0, + response_destination: "", + custom_payload: null, + forward_ton_amount: "", + forward_payload: null, + }, + ], +}; + export const tonTransactionResponse: TonTransactionsList = { transactions: [ { diff --git a/libs/coin-modules/coin-ton/src/__tests__/integration/bridge.integration.test.ts b/libs/coin-modules/coin-ton/src/__tests__/integration/bridge.integration.test.ts index f258b6399605..ede4fa0b745e 100644 --- a/libs/coin-modules/coin-ton/src/__tests__/integration/bridge.integration.test.ts +++ b/libs/coin-modules/coin-ton/src/__tests__/integration/bridge.integration.test.ts @@ -1,3 +1,4 @@ +import { findSubAccountById } from "@ledgerhq/coin-framework/account/index"; import { InvalidAddress, NotEnoughBalance } from "@ledgerhq/errors"; import { CurrenciesData, DatasetTest } from "@ledgerhq/types-live"; import BigNumber from "bignumber.js"; @@ -9,8 +10,11 @@ const PUBKEY = "86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060 const ADDRESS = "UQCOvQLYvTcbi5tL9MaDNzuVl3-J3vATimNm9yO5XPafLfV4"; const ADDRESS_2 = "UQAui6M4jOYOezUGfmeONA22Ars9yjd34YIGdAR1Pcpp4sgR"; const PATH = "44'/607'/0'/0'/0'/0'"; +const SUBACCOUNT = + "js:2:ton:86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060:ton+ton%2Fjetton%2Feqbynbo23ywhy~!underscore!~cgary9nk9ftz0ydsg82ptcbstqggoxwiua"; const ton: CurrenciesData = { + IgnorePrepareTransactionFields: ["fees"], scanAccounts: [ { name: "ton seed 1", @@ -85,13 +89,13 @@ const ton: CurrenciesData = { transaction: fromTransactionRaw({ family: "ton", recipient: ADDRESS_2, - fees: "10000000", - amount: (1 * 1e9).toString(), + fees: "1", + amount: (1 * 1e2).toString(), comment: { isEncrypted: false, text: "😀" }, }), expectedStatus: { errors: { - comment: new TonCommentInvalid(), + transaction: new TonCommentInvalid(), }, warnings: {}, }, @@ -111,6 +115,77 @@ const ton: CurrenciesData = { warnings: {}, }, }, + // sub account tests + { + name: "Subaccount Not a valid address", + transaction: fromTransactionRaw({ + family: "ton", + recipient: "novalidaddress", + fees: "10000000", + amount: "1000", + comment: { isEncrypted: false, text: "" }, + subAccountId: SUBACCOUNT, + }), + expectedStatus: { + errors: { + recipient: new InvalidAddress(), + }, + warnings: {}, + }, + }, + { + name: "Subaccount Not enough balance", + transaction: fromTransactionRaw({ + family: "ton", + recipient: ADDRESS_2, + fees: "10000000", + amount: (300 * 1e9).toString(), + comment: { isEncrypted: false, text: "" }, + subAccountId: SUBACCOUNT, + }), + expectedStatus: { + errors: { + amount: new NotEnoughBalance(), + }, + warnings: {}, + }, + }, + { + name: "Subaccount New account and sufficient amount", + transaction: fromTransactionRaw({ + family: "ton", + recipient: ADDRESS_2, + fees: "10000000", + amount: "10000000", + comment: { isEncrypted: false, text: "Valid" }, + subAccountId: SUBACCOUNT, + }), + expectedStatus: { + amount: new BigNumber("10000000"), + errors: {}, + warnings: {}, + }, + }, + { + name: "Subaccount Send max", + transaction: fromTransactionRaw({ + family: "ton", + recipient: ADDRESS_2, + fees: "10000000", + amount: "10000000", + comment: { isEncrypted: false, text: "Valid" }, + useAllAmount: true, + subAccountId: SUBACCOUNT, + }), + expectedStatus: (account, tx) => { + const subAccount = findSubAccountById(account, tx.subAccountId ?? ""); + return { + amount: subAccount?.spendableBalance, + errors: {}, + warnings: {}, + }; + }, + }, ], }, ], diff --git a/libs/coin-modules/coin-ton/src/__tests__/unit/api.unit.test.ts b/libs/coin-modules/coin-ton/src/__tests__/unit/api.unit.test.ts index ef29c1f3877c..9918e96931f0 100644 --- a/libs/coin-modules/coin-ton/src/__tests__/unit/api.unit.test.ts +++ b/libs/coin-modules/coin-ton/src/__tests__/unit/api.unit.test.ts @@ -1,12 +1,16 @@ import { estimateFee, fetchAccountInfo, + fetchJettonTransactions, + fetchJettonWallets, fetchLastBlockNumber, fetchTransactions, } from "../../bridge/bridgeHelpers/api"; import { setCoinConfig } from "../../config"; import mockServer, { API_TON_ENDPOINT } from "../fixtures/api.fixtures"; import { + jettonTransferResponse, + jettonWallets, lastBlockNumber, mockAddress, tonAccount, @@ -53,6 +57,16 @@ describe("getAccount", () => { }); }); + it("should return the jetton transactions", async () => { + const result = await fetchJettonTransactions(mockAddress); + expect(result).toEqual(jettonTransferResponse.jetton_transfers); + }); + + it("should return the jetton wallets", async () => { + const result = await fetchJettonWallets(); + expect(result).toEqual(jettonWallets.jetton_wallets); + }); + it("should return the estimated fees", async () => { const result = await estimateFee(mockAddress, ""); expect(result).toEqual(tonEstimateFee.source_fees); diff --git a/libs/coin-modules/coin-ton/src/__tests__/unit/deviceTransactionConfig.unit.test.ts b/libs/coin-modules/coin-ton/src/__tests__/unit/deviceTransactionConfig.unit.test.ts index bc74b8857a57..93d9d54517bc 100644 --- a/libs/coin-modules/coin-ton/src/__tests__/unit/deviceTransactionConfig.unit.test.ts +++ b/libs/coin-modules/coin-ton/src/__tests__/unit/deviceTransactionConfig.unit.test.ts @@ -1,6 +1,11 @@ import BigNumber from "bignumber.js"; +import { TOKEN_TRANSFER_MAX_FEE } from "../../constants"; import getDeviceTransactionConfig from "../../deviceTransactionConfig"; -import { account, transaction as baseTransaction } from "../fixtures/common.fixtures"; +import { + account, + transaction as baseTransaction, + jettonTransaction, +} from "../fixtures/common.fixtures"; const status = { errors: {}, @@ -66,4 +71,34 @@ describe("deviceTransactionConfig", () => { ]); }); }); + + describe("Jetton transaction", () => { + it("should return the fields for a jetton transaction", async () => { + if (account.subAccounts?.[0]) { + const res = await getDeviceTransactionConfig({ + account: account.subAccounts[0], + parentAccount: account, + transaction: jettonTransaction, + status, + }); + expect(res).toEqual([ + { + type: "address", + label: "To", + address: jettonTransaction.recipient, + }, + { + type: "text", + label: "Jetton units", + value: jettonTransaction.amount.toString(), + }, + { + type: "text", + label: "Amount", + value: TOKEN_TRANSFER_MAX_FEE, + }, + ]); + } + }); + }); }); diff --git a/libs/coin-modules/coin-ton/src/__tests__/unit/estimateMaxSpendable.unit.test.ts b/libs/coin-modules/coin-ton/src/__tests__/unit/estimateMaxSpendable.unit.test.ts index fcd3639961fa..4bf62cfad1c6 100644 --- a/libs/coin-modules/coin-ton/src/__tests__/unit/estimateMaxSpendable.unit.test.ts +++ b/libs/coin-modules/coin-ton/src/__tests__/unit/estimateMaxSpendable.unit.test.ts @@ -1,6 +1,14 @@ import { estimateFee, fetchAccountInfo } from "../../bridge/bridgeHelpers/api"; import estimateMaxSpendable from "../../estimateMaxSpendable"; -import { account, accountInfo, fees, totalFees, transaction } from "../fixtures/common.fixtures"; +import { + account, + accountInfo, + fees, + jettonTransaction, + tokenAccount, + totalFees, + transaction, +} from "../fixtures/common.fixtures"; jest.mock("../../bridge/bridgeHelpers/api"); @@ -16,4 +24,13 @@ describe("estimateMaxSpendable", () => { const res = await estimateMaxSpendable({ account, transaction }); expect(res).toEqual(account.balance.minus(totalFees)); }); + + it("should return the max spendable for a jetton transfer", async () => { + const res = await estimateMaxSpendable({ + account: tokenAccount, + parentAccount: account, + transaction: jettonTransaction, + }); + expect(res).toEqual(tokenAccount.spendableBalance); + }); }); diff --git a/libs/coin-modules/coin-ton/src/__tests__/unit/getTransactionStatus.unit.test.ts b/libs/coin-modules/coin-ton/src/__tests__/unit/getTransactionStatus.unit.test.ts index 810020b5513f..32ea320c26d2 100644 --- a/libs/coin-modules/coin-ton/src/__tests__/unit/getTransactionStatus.unit.test.ts +++ b/libs/coin-modules/coin-ton/src/__tests__/unit/getTransactionStatus.unit.test.ts @@ -6,9 +6,13 @@ import { RecipientRequired, } from "@ledgerhq/errors"; import BigNumber from "bignumber.js"; -import { TonCommentInvalid } from "../../errors"; +import { TonCommentInvalid, TonExcessFee } from "../../errors"; import getTransactionStatus from "../../getTransactionStatus"; -import { account, transaction as baseTransaction } from "../fixtures/common.fixtures"; +import { + account, + transaction as baseTransaction, + jettonTransaction, +} from "../fixtures/common.fixtures"; describe("getTransactionStatus", () => { describe("Recipient", () => { @@ -87,10 +91,39 @@ describe("getTransactionStatus", () => { ); }); + it("should detect the amount is greater than the spendable amount of the token account and have an error", async () => { + const transaction = { + ...jettonTransaction, + amount: BigNumber(1000000002), + fees: new BigNumber("20"), + }; + const res = await getTransactionStatus(account, transaction); + expect(res.errors).toEqual( + expect.objectContaining({ + amount: new NotEnoughBalance(), + }), + ); + }); + + it("should detect the transaction is a jetton transfer and have a warning", async () => { + const transaction = { + ...jettonTransaction, + amount: BigNumber(1000000002), + fees: new BigNumber("20"), + }; + const res = await getTransactionStatus(account, transaction); + expect(res.warnings).toEqual( + expect.objectContaining({ + amount: new TonExcessFee(), + }), + ); + }); + describe("Comment", () => { it("should detect the comment is not valid and have an error", async () => { const transaction = { ...baseTransaction, + amount: new BigNumber("1"), comment: { isEncrypted: false, text: "comment\nInvalid" }, }; const res = await getTransactionStatus(account, transaction); diff --git a/libs/coin-modules/coin-ton/src/__tests__/unit/logic.unit.test.ts b/libs/coin-modules/coin-ton/src/__tests__/unit/logic.unit.test.ts new file mode 100644 index 000000000000..10121c41a5b3 --- /dev/null +++ b/libs/coin-modules/coin-ton/src/__tests__/unit/logic.unit.test.ts @@ -0,0 +1,16 @@ +import { getCryptoCurrencyById, getTokenById } from "@ledgerhq/cryptoassets"; +import { getSyncHash } from "../../logic"; + +describe("getSyncHash", () => { + const currency = getCryptoCurrencyById("ton"); + + it("should provide a valid hex hash", () => { + // mumurhash is always returning a 32bits uint, so a 4 bytes hexa string + expect(getSyncHash(currency, [])).toStrictEqual(expect.stringMatching(/^0x[A-Fa-f0-9]{8}$/)); + }); + + it("should provide a new hash if a token is added to the blacklistedTokenIds", () => { + const token = getTokenById("ton/jetton/eqcxe6mutqjkfngfarotkot1lzbdiix1kcixrv7nw2id_sds"); + expect(getSyncHash(currency, [])).not.toEqual(getSyncHash(currency, [token.id])); + }); +}); diff --git a/libs/coin-modules/coin-ton/src/__tests__/unit/prepareTransaction.unit.test.ts b/libs/coin-modules/coin-ton/src/__tests__/unit/prepareTransaction.unit.test.ts index 6da681b83059..9b058b9eeca1 100644 --- a/libs/coin-modules/coin-ton/src/__tests__/unit/prepareTransaction.unit.test.ts +++ b/libs/coin-modules/coin-ton/src/__tests__/unit/prepareTransaction.unit.test.ts @@ -5,6 +5,7 @@ import { accountInfo, transaction as baseTransaction, fees, + jettonTransaction, totalFees, } from "../fixtures/common.fixtures"; @@ -49,4 +50,36 @@ describe("prepareTransaction", () => { }); }); }); + + describe("Jetton Transaction", () => { + it("should return the transaction with the updated amount and fees", async () => { + const transaction = await prepareTransaction(account, jettonTransaction); + + expect(transaction).toEqual({ + ...jettonTransaction, + fees: totalFees, + }); + }); + + it("should preserve the reference when no change is detected on the transaction", async () => { + const transaction = await prepareTransaction(account, { ...jettonTransaction }); + const transaction2 = await prepareTransaction(account, transaction); + + expect(transaction).toBe(transaction2); + }); + + it("should create a coin transaction using the spendableBalance in the account", async () => { + const transaction = await prepareTransaction(account, { + ...jettonTransaction, + useAllAmount: true, + }); + + expect(transaction).toEqual({ + ...jettonTransaction, + useAllAmount: true, + fees: totalFees, + amount: account.subAccounts?.[0].spendableBalance, + }); + }); + }); }); diff --git a/libs/coin-modules/coin-ton/src/__tests__/unit/txn.unit.test.ts b/libs/coin-modules/coin-ton/src/__tests__/unit/txn.unit.test.ts index 2e9cb8943526..b9234d18543b 100644 --- a/libs/coin-modules/coin-ton/src/__tests__/unit/txn.unit.test.ts +++ b/libs/coin-modules/coin-ton/src/__tests__/unit/txn.unit.test.ts @@ -2,9 +2,14 @@ import { encodeOperationId } from "@ledgerhq/coin-framework/lib/operation"; import BigNumber from "bignumber.js"; // eslint-disable-next-line no-restricted-imports import { flatMap } from "lodash"; -import { TonTransaction } from "../../bridge/bridgeHelpers/api.types"; -import { mapTxToOps } from "../../bridge/bridgeHelpers/txn"; -import { mockAccountId, mockAddress, tonTransactionResponse } from "../fixtures/common.fixtures"; +import { TonJettonTransfer, TonTransaction } from "../../bridge/bridgeHelpers/api.types"; +import { mapJettonTxToOps, mapTxToOps } from "../../bridge/bridgeHelpers/txn"; +import { + jettonTransferResponse, + mockAccountId, + mockAddress, + tonTransactionResponse, +} from "../fixtures/common.fixtures"; describe("Transaction functions", () => { describe("mapTxToOps", () => { @@ -118,4 +123,80 @@ describe("Transaction functions", () => { ]); }); }); + + describe("mapJettonToOps", () => { + it("should map an IN ton transaction without total_fees to a ledger operation", async () => { + const { transaction_hash, amount, transaction_now, transaction_lt } = + jettonTransferResponse.jetton_transfers[0]; + + const finalOperation = flatMap( + jettonTransferResponse.jetton_transfers, + mapJettonTxToOps(mockAccountId, mockAddress, tonTransactionResponse.address_book), + ); + + const tokenByCurrencyAddress = `${mockAccountId}+ton%2Fjetton%2Feqbynbo23ywhy~!underscore!~cgary9nk9ftz0ydsg82ptcbstqggoxwiua`; + expect(finalOperation).toEqual([ + { + id: encodeOperationId(tokenByCurrencyAddress, transaction_hash, "IN"), + hash: transaction_hash, + type: "IN", + value: BigNumber(amount), + fee: BigNumber(0), + blockHeight: 1, + blockHash: null, + hasFailed: false, + accountId: tokenByCurrencyAddress, + senders: ["EQDnqcVSV4S9m2Y9gLAQrDerQktKSx2I1uhs6r5o_H8VT9G-"], + recipients: [mockAddress], + date: new Date(transaction_now * 1000), // now is defined in seconds + extra: { + comment: { isEncrypted: false, text: "" }, + explorerHash: transaction_hash, + lt: transaction_lt, + }, + }, + ]); + }); + + it("should map an OUT jetton transaction to a ledger operation", async () => { + // The IN jetton transaction will be used as OUT transaction and it will be adjusted + const jettonTransfers: TonJettonTransfer[] = [ + { + ...jettonTransferResponse.jetton_transfers[0], + }, + ]; + jettonTransfers[0].source = jettonTransfers[0].destination; + jettonTransfers[0].destination = jettonTransferResponse.jetton_transfers[0].source; + + const { transaction_hash, amount, transaction_now, transaction_lt } = jettonTransfers[0]; + + const finalOperation = flatMap( + jettonTransfers, + mapJettonTxToOps(mockAccountId, mockAddress, tonTransactionResponse.address_book), + ); + + const tokenByCurrencyAddress = `${mockAccountId}+ton%2Fjetton%2Feqbynbo23ywhy~!underscore!~cgary9nk9ftz0ydsg82ptcbstqggoxwiua`; + expect(finalOperation).toEqual([ + { + id: encodeOperationId(tokenByCurrencyAddress, transaction_hash, "OUT"), + hash: transaction_hash, + type: "OUT", + value: BigNumber(amount), + fee: BigNumber(0), + blockHeight: 1, + blockHash: null, + hasFailed: false, + accountId: tokenByCurrencyAddress, + recipients: ["EQDnqcVSV4S9m2Y9gLAQrDerQktKSx2I1uhs6r5o_H8VT9G-"], + senders: [mockAddress], + date: new Date(transaction_now * 1000), // now is defined in seconds + extra: { + comment: { isEncrypted: false, text: "" }, + explorerHash: transaction_hash, + lt: transaction_lt, + }, + }, + ]); + }); + }); }); diff --git a/libs/coin-modules/coin-ton/src/__tests__/unit/utils.unit.test.ts b/libs/coin-modules/coin-ton/src/__tests__/unit/utils.unit.test.ts index 064e8b6fa46c..28f730e8b85d 100644 --- a/libs/coin-modules/coin-ton/src/__tests__/unit/utils.unit.test.ts +++ b/libs/coin-modules/coin-ton/src/__tests__/unit/utils.unit.test.ts @@ -1,5 +1,10 @@ -import { Address } from "@ton/core"; -import { TonComment } from "../../types"; +import { Address, toNano } from "@ton/core"; +import { + TOKEN_TRANSFER_FORWARD_AMOUNT, + TOKEN_TRANSFER_MAX_FEE, + TOKEN_TRANSFER_QUERY_ID, +} from "../../constants"; +import { TonComment, TonPayloadFormat, TonPayloadJettonTransfer } from "../../types"; import { addressesAreEqual, buildTonTransaction, @@ -8,7 +13,11 @@ import { getTransferExpirationTime, isAddressValid, } from "../../utils"; -import { transaction as baseTransaction } from "../fixtures/common.fixtures"; +import { + account, + transaction as baseTransaction, + jettonTransaction, +} from "../fixtures/common.fixtures"; describe("TON addresses", () => { const addr = { @@ -66,6 +75,46 @@ test("TON Comments are valid", () => { expect(commentIsValid(msg(true, ""))).toBe(false); }); +describe("TON transfers", () => { + const commentPayload: TonPayloadFormat = { + type: "comment", + text: "", + }; + + const transferPayload: TonPayloadFormat = { + type: "jetton-transfer", + queryId: null, + amount: BigInt(0), + destination: new Address(0, Buffer.alloc(32)), + responseDestination: new Address(0, Buffer.alloc(32)), + customPayload: null, + forwardAmount: BigInt(0), + forwardPayload: null, + }; + + const nftPayload: TonPayloadFormat = { + type: "nft-transfer", + queryId: null, + newOwner: new Address(0, Buffer.alloc(32)), + responseDestination: new Address(0, Buffer.alloc(32)), + customPayload: null, + forwardAmount: BigInt(0), + forwardPayload: null, + }; + + test("Check if the transaction is jetton transfer", () => { + /** + * Checks if the given payload is a jetton transfer. + */ + const isJettonTransfer = (payload: TonPayloadFormat): boolean => + payload.type === "jetton-transfer"; + + expect(isJettonTransfer(commentPayload)).toBe(false); + expect(isJettonTransfer(transferPayload)).toBe(true); + expect(isJettonTransfer(nftPayload)).toBe(false); + }); +}); + describe("Get TON paths", () => { const correctPath = ["44'/607'/0'/0'/0'/0'", "m/44'/607'/0'/0'/0'/0'"]; const wrongPaths = [ @@ -93,7 +142,7 @@ describe("Build TON transaction", () => { const seqno = 22; test("Build TON transaction with an specific amount", () => { - const tonTransaction = buildTonTransaction(baseTransaction, seqno); + const tonTransaction = buildTonTransaction(baseTransaction, seqno, account); // Convert the Address to string to compare expect({ ...tonTransaction, to: tonTransaction.to.toString() }).toEqual({ @@ -107,10 +156,12 @@ describe("Build TON transaction", () => { }); test("Build TON transaction when useAllAmount is true and there is a comment", () => { - const transaction = { ...baseTransaction }; - transaction.useAllAmount = true; - transaction.comment.text = "valid coment"; - const tonTransaction = buildTonTransaction(transaction, seqno); + const transaction = { + ...baseTransaction, + comment: { text: "valid comment", isEncrypted: false }, + useAllAmount: true, + }; + const tonTransaction = buildTonTransaction(transaction, seqno, account); // Convert the Address to string to compare expect({ ...tonTransaction, to: tonTransaction.to.toString() }).toEqual({ @@ -123,4 +174,45 @@ describe("Build TON transaction", () => { payload: { type: "comment", text: transaction.comment.text }, }); }); + + test("Build jetton transaction with an specific amount", () => { + const jettonTransfer = buildTonTransaction(jettonTransaction, seqno, account); + + // Convert the Addresses to string to compare + expect({ + ...jettonTransfer, + to: jettonTransfer.to.toString(), + payload: undefined, + }).toStrictEqual({ + to: Address.parse(account.subAccounts?.[0].token.contractAddress ?? "").toString(), + seqno, + amount: toNano(TOKEN_TRANSFER_MAX_FEE), + bounce: true, + timeout: getTransferExpirationTime(), + sendMode: 3, + payload: undefined, + }); + + expect(jettonTransfer.payload?.type).toStrictEqual("jetton-transfer"); + + expect({ + ...jettonTransfer.payload, + destination: (jettonTransfer.payload as TonPayloadJettonTransfer).destination.toString(), + responseDestination: ( + jettonTransfer.payload as TonPayloadJettonTransfer + ).responseDestination.toString(), + queryId: (jettonTransfer.payload as TonPayloadJettonTransfer).queryId?.toString(), + amount: (jettonTransfer.payload as TonPayloadJettonTransfer).amount.toString(), + forwardAmount: (jettonTransfer.payload as TonPayloadJettonTransfer).forwardAmount.toString(), + }).toStrictEqual({ + type: "jetton-transfer", + queryId: TOKEN_TRANSFER_QUERY_ID.toString(), + amount: jettonTransaction.amount.toFixed(), + destination: Address.parse(jettonTransaction.recipient).toString(), + responseDestination: Address.parse(account.freshAddress).toString(), + customPayload: null, + forwardAmount: TOKEN_TRANSFER_FORWARD_AMOUNT.toString(), + forwardPayload: null, + }); + }); }); diff --git a/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/api.ts b/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/api.ts index 008ec4435760..bd6911784d7b 100644 --- a/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/api.ts +++ b/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/api.ts @@ -4,8 +4,12 @@ import { getCoinConfig } from "../../config"; import { TonAccountInfo, TonFee, + TonJettonTransfer, + TonJettonWallet, TonResponseAccountInfo, TonResponseEstimateFee, + TonResponseJettonTransfer, + TonResponseJettonWallets, TonResponseMasterchainInfo, TonResponseMessage, TonResponseWalletInfo, @@ -82,6 +86,37 @@ export async function fetchAccountInfo(addr: string): Promise { }; } +export async function fetchJettonTransactions( + addr: string, + opts?: { + jettonMaster?: string; + startLt?: string; + endLt?: string; + }, +): Promise { + const address = Address.parse(addr); + const urlAddr = address.toString({ bounceable: false, urlSafe: true }); + let url = `/jetton/transfers?address=${urlAddr}&limit=256`; + if (opts?.jettonMaster != null) url += `&jetton_master=${opts.jettonMaster}`; + if (opts?.startLt != null) url += `&start_lt=${opts.startLt}`; + if (opts?.endLt != null) url += `&end_lt=${opts.endLt}`; + return (await fetch(url)).jetton_transfers; +} + +export async function fetchJettonWallets(opts?: { + address?: string; + jettonMaster?: string; +}): Promise { + let url = `/jetton/wallets?limit=256`; + if (opts?.jettonMaster != null) url += `&jetton_address=${opts.jettonMaster}`; + if (opts?.address != null) { + const address = Address.parse(opts.address); + const urlAddr = address.toString({ bounceable: false, urlSafe: true }); + url += `&owner_address=${urlAddr}`; + } + return (await fetch(url)).jetton_wallets; +} + export async function estimateFee( address: string, body: string, diff --git a/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/api.types.ts b/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/api.types.ts index adab71fdbc62..163295881242 100644 --- a/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/api.types.ts +++ b/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/api.types.ts @@ -123,6 +123,32 @@ export interface TonAddressBook { }; } +export interface TonJettonTransfer { + query_id: string; + source: string; + destination: string; + amount: string; + source_wallet: string; + jetton_master: string; + transaction_hash: string; + transaction_lt: string; + transaction_now: number; + response_destination: string | null; + custom_payload: string | null; + forward_ton_amount: string | null; + forward_payload: string | null; +} + +export interface TonJettonWallet { + address: string; + balance: string; + owner: string; + jetton: string; + last_transaction_lt: string; + code_hash: string; + data_hash: string; +} + export interface TonAccountInfo { balance: string; last_transaction_lt: string | null; @@ -168,6 +194,14 @@ export interface TonResponseWalletInfo { status: TonAccountStatus; } +export interface TonResponseJettonTransfer { + jetton_transfers: TonJettonTransfer[]; +} + +export interface TonResponseJettonWallets { + jetton_wallets: TonJettonWallet[]; +} + export interface TonResponseEstimateFee { source_fees: TonFee; destination_fees: TonFee[]; diff --git a/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/txn.ts b/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/txn.ts index e8b19084275c..f09355b29174 100644 --- a/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/txn.ts +++ b/libs/coin-modules/coin-ton/src/bridge/bridgeHelpers/txn.ts @@ -1,11 +1,18 @@ +import { decodeAccountId, encodeTokenAccountId } from "@ledgerhq/coin-framework/account/index"; import { encodeOperationId } from "@ledgerhq/coin-framework/operation"; +import { findTokenByAddressInCurrency } from "@ledgerhq/cryptoassets/tokens"; import { Operation } from "@ledgerhq/types-live"; -import { Address } from "@ton/ton"; +import { Address, Cell } from "@ton/core"; import BigNumber from "bignumber.js"; import { TonOperation } from "../../types"; -import { isAddressValid } from "../../utils"; -import { fetchTransactions } from "./api"; -import { TonAddressBook, TonTransaction, TonTransactionsList } from "./api.types"; +import { addressesAreEqual, isAddressValid } from "../../utils"; +import { fetchJettonTransactions, fetchTransactions } from "./api"; +import { + TonAddressBook, + TonJettonTransfer, + TonTransaction, + TonTransactionsList, +} from "./api.types"; export async function getTransactions( addr: string, @@ -33,6 +40,32 @@ export async function getTransactions( return txs; } +export async function getJettonTransfers( + addr: string, + startLt?: string, +): Promise { + const txs = await fetchJettonTransactions(addr, { startLt }); + if (txs.length === 0) return txs; + let tmpTxs: TonJettonTransfer[]; + let isUncompletedResult = true; + + while (isUncompletedResult) { + const { transaction_hash, transaction_lt } = txs[txs.length - 1]; + tmpTxs = await fetchJettonTransactions(addr, { startLt, endLt: transaction_lt }); + // we found the last transaction + if (tmpTxs.length === 1) { + isUncompletedResult = false; + break; + } + // it should always match + if (transaction_hash !== tmpTxs[0].transaction_hash) + throw Error("[ton] transaction hash does not match"); + tmpTxs.shift(); // first element is repeated + txs.push(...tmpTxs); + } + return txs; +} + function getFriendlyAddress(addressBook: TonAddressBook, rawAddr?: string | null): string[] { if (!rawAddr) return []; if (addressBook[rawAddr]) return [addressBook[rawAddr].user_friendly]; @@ -164,3 +197,109 @@ export function mapTxToOps( return ops; }; } + +export function mapJettonTxToOps( + accountId: string, + addr: string, + addressBook: TonAddressBook, +): (tx: TonJettonTransfer) => TonOperation[] { + return (tx: TonJettonTransfer): TonOperation[] => { + const accountAddr = Address.parse(addr).toString({ urlSafe: true, bounceable: false }); + if (accountAddr !== addr) throw Error(`[ton] unexpected address ${accountAddr} ${addr}`); + + const jettonMasterAddr = Address.parse(tx.jetton_master).toString({ + urlSafe: true, + bounceable: true, + }); + const tokenCurrency = findTokenByAddressInCurrency( + jettonMasterAddr.toLowerCase(), + decodeAccountId(accountId).currencyId, + ); + if (!tokenCurrency) return []; + const tokenAccountId = encodeTokenAccountId(accountId, tokenCurrency); + + const ops: TonOperation[] = []; + const isReceiving = addressesAreEqual( + accountAddr, + Address.parse(tx.destination).toString({ urlSafe: true, bounceable: false }), + ); + const isSending = addressesAreEqual( + accountAddr, + Address.parse(tx.source).toString({ urlSafe: true, bounceable: false }), + ); + if (!isSending && !isReceiving) throw Error("[ton] unexpected addresses"); + + const date = new Date(tx.transaction_now * 1000); // now is defined in seconds + const hash = tx.transaction_hash; + + if (isReceiving) { + ops.push({ + id: encodeOperationId(tokenAccountId, hash, "IN"), + hash, + type: "IN", + value: BigNumber(tx.amount), + fee: BigNumber(0), + blockHeight: 1, // we don't have block info + blockHash: null, + hasFailed: false, + accountId: tokenAccountId, + senders: getFriendlyAddress(addressBook, tx.source), + recipients: [accountAddr], + date, + extra: { + lt: tx.transaction_lt, + explorerHash: hash, + comment: { + isEncrypted: false, + text: tx.forward_payload ? decodeForwardPayload(tx.forward_payload) : "", + }, + }, + }); + } + + if (isSending) { + ops.push({ + id: encodeOperationId(tokenAccountId, hash, "OUT"), + hash, + type: "OUT", + value: BigNumber(tx.amount), + fee: BigNumber(0), + blockHeight: 1, // we don't have block info + blockHash: null, + hasFailed: false, + accountId: tokenAccountId, + senders: [accountAddr], + recipients: getFriendlyAddress(addressBook, tx.destination), + date, + extra: { + lt: tx.transaction_lt, + explorerHash: hash, + comment: { + isEncrypted: false, + text: tx.forward_payload ? decodeForwardPayload(tx.forward_payload) : "", + }, + }, + }); + } + + return ops; + }; +} + +function decodeForwardPayload(payload: string | null): string { + if (!payload) return ""; + + const decodedBuffer = Buffer.from(payload, "base64"); + const cell = Cell.fromBoc(decodedBuffer)[0]; + const slice = cell.beginParse(); + + // Read the opcode + const opcode = slice.loadUint(32); + if (opcode !== 0) { + return ""; + } + + // Read the comment + const comment = slice.loadStringTail(); + return comment; +} diff --git a/libs/coin-modules/coin-ton/src/cli-transaction.ts b/libs/coin-modules/coin-ton/src/cli-transaction.ts index c0dc11e6fe11..2518f9646cf9 100644 --- a/libs/coin-modules/coin-ton/src/cli-transaction.ts +++ b/libs/coin-modules/coin-ton/src/cli-transaction.ts @@ -11,13 +11,21 @@ function inferTransactions( }>, opts: Record, ): Transaction[] { - return flatMap(transactions, ({ transaction }) => { + return flatMap(transactions, ({ transaction, account }) => { invariant(transaction.family === "ton", "ton family"); + const isTokenAccount = account.type === "TokenAccount"; + + if (isTokenAccount) { + const isDelisted = account.token.delisted === true; + invariant(!isDelisted, "token is delisted"); + } + return { ...transaction, family: "ton", mode: opts.mode || "send", + subAccountId: isTokenAccount ? account.id : null, } as Transaction; }); } diff --git a/libs/coin-modules/coin-ton/src/constants.ts b/libs/coin-modules/coin-ton/src/constants.ts new file mode 100644 index 000000000000..728d215d5b9f --- /dev/null +++ b/libs/coin-modules/coin-ton/src/constants.ts @@ -0,0 +1,36 @@ +/** + * Maximum commission fee for jetton transactions in TON units. + * Any excess fee will be returned to the user. + */ +export const TOKEN_TRANSFER_MAX_FEE = "0.1"; // 0.1 TON + +/** + * Minimum required balance in TON units. + * This is the minimum balance required to perform transactions. + */ +export const MINIMUM_REQUIRED_BALANCE = "0.02"; // 0.02 TON + +/** + * Forward amount for token transfers in nanoTON units. + */ +export const TOKEN_TRANSFER_FORWARD_AMOUNT = 1; // 0.000000001 TON + +/** + * Query ID for token transfers. + */ +export const TOKEN_TRANSFER_QUERY_ID = 0; + +/** + * Maximum allowed bytes for a comment in a transaction. + * Comments exceeding this limit will be considered invalid. + */ +export const MAX_COMMENT_BYTES = 120; + +export enum JettonOpCode { + Transfer = 0xf8a7ea5, + TransferNotification = 0x7362d09c, + InternalTransfer = 0x178d4519, + Excesses = 0xd53276db, + Burn = 0x595f07bc, + BurnNotification = 0x7bdd97de, +} diff --git a/libs/coin-modules/coin-ton/src/deviceTransactionConfig.ts b/libs/coin-modules/coin-ton/src/deviceTransactionConfig.ts index 1eba181d0967..ca801a2b108e 100644 --- a/libs/coin-modules/coin-ton/src/deviceTransactionConfig.ts +++ b/libs/coin-modules/coin-ton/src/deviceTransactionConfig.ts @@ -1,5 +1,7 @@ +import { isTokenAccount } from "@ledgerhq/coin-framework/account/index"; import { CommonDeviceTransactionField as DeviceTransactionField } from "@ledgerhq/coin-framework/transaction/common"; import type { Account, AccountLike } from "@ledgerhq/types-live"; +import { TOKEN_TRANSFER_MAX_FEE } from "./constants"; import type { Transaction, TransactionStatus } from "./types"; function getDeviceTransactionConfig(input: { @@ -9,6 +11,7 @@ function getDeviceTransactionConfig(input: { status: TransactionStatus; }): Array { const fields: Array = []; + const tokenTransfer = Boolean(input.account && isTokenAccount(input.account)); fields.push({ type: "address", @@ -16,24 +19,36 @@ function getDeviceTransactionConfig(input: { address: input.transaction.recipient, }); - if (input.transaction.useAllAmount) { + if (tokenTransfer) { + fields.push({ + type: "text", + label: "Jetton units", + value: input.transaction.amount.toString(), + }); fields.push({ type: "text", label: "Amount", - value: "ALL YOUR TONs", + value: TOKEN_TRANSFER_MAX_FEE, }); } else { + if (input.transaction.useAllAmount) { + fields.push({ + type: "text", + label: "Amount", + value: "ALL YOUR TONs", + }); + } else { + fields.push({ + type: "amount", + label: "Amount", + }); + } fields.push({ - type: "amount", - label: "Amount", + type: "fees", + label: "Fee", }); } - fields.push({ - type: "fees", - label: "Fee", - }); - if (!input.transaction.comment.isEncrypted && input.transaction.comment.text) { fields.push({ type: "text", diff --git a/libs/coin-modules/coin-ton/src/errors.ts b/libs/coin-modules/coin-ton/src/errors.ts index 5ae1da5285db..ff283eb84cac 100644 --- a/libs/coin-modules/coin-ton/src/errors.ts +++ b/libs/coin-modules/coin-ton/src/errors.ts @@ -4,3 +4,20 @@ import { createCustomErrorClass } from "@ledgerhq/errors"; * When the comment is invalid. */ export const TonCommentInvalid = createCustomErrorClass("TonCommentInvalid"); + +/* + * When the transaction is a jetton transfer. + */ +export const TonMinimumRequired = createCustomErrorClass("TonMinimumRequired"); + +/* + * When the transaction is a jetton transfer. + */ +export const TonExcessFee = createCustomErrorClass("TonExcessFee"); + +/* + * When the transaction is a jetton transfer. + */ +export const TonNotEnoughBalanceInParentAccount = createCustomErrorClass( + "TonNotEnoughBalanceInParentAccount", +); diff --git a/libs/coin-modules/coin-ton/src/estimateMaxSpendable.ts b/libs/coin-modules/coin-ton/src/estimateMaxSpendable.ts index 666773f68f0a..1ec1edf1aa38 100644 --- a/libs/coin-modules/coin-ton/src/estimateMaxSpendable.ts +++ b/libs/coin-modules/coin-ton/src/estimateMaxSpendable.ts @@ -1,5 +1,9 @@ -import { getMainAccount } from "@ledgerhq/coin-framework/account/index"; -import type { Account, AccountBridge, AccountLike } from "@ledgerhq/types-live"; +import { + findSubAccountById, + getMainAccount, + isTokenAccount, +} from "@ledgerhq/coin-framework/account/index"; +import type { Account, AccountBridge, AccountLike, TokenAccount } from "@ledgerhq/types-live"; import { BigNumber } from "bignumber.js"; import { fetchAccountInfo } from "./bridge/bridgeHelpers/api"; import type { Transaction } from "./types"; @@ -20,13 +24,33 @@ const estimateMaxSpendable: AccountBridge["estimateMaxSpen if (balance.eq(0)) return balance; const accountInfo = await fetchAccountInfo(mainAccount.freshAddress); + const isTokenType = isTokenAccount(account); + + if (transaction && !transaction.subAccountId) { + transaction.subAccountId = isTokenType ? account.id : null; + } + + let tokenAccountTxn: boolean = false; + let subAccount: TokenAccount | undefined | null; + if (isTokenType) { + tokenAccountTxn = true; + subAccount = account; + } + if (transaction?.subAccountId && !subAccount) { + tokenAccountTxn = true; + subAccount = findSubAccountById(mainAccount, transaction.subAccountId || "") ?? null; + } + + if (tokenAccountTxn && subAccount) { + return subAccount.spendableBalance; + } const estimatedFees = transaction ? transaction.fees ?? (await getTonEstimatedFees( mainAccount, accountInfo.status === "uninit", - buildTonTransaction(transaction, accountInfo.seqno), + buildTonTransaction(transaction, accountInfo.seqno, mainAccount), )) : BigNumber(0); diff --git a/libs/coin-modules/coin-ton/src/getTransactionStatus.ts b/libs/coin-modules/coin-ton/src/getTransactionStatus.ts index 60c0e78f36e8..ffd7f1a53a7e 100644 --- a/libs/coin-modules/coin-ton/src/getTransactionStatus.ts +++ b/libs/coin-modules/coin-ton/src/getTransactionStatus.ts @@ -1,3 +1,4 @@ +import { findSubAccountById, isTokenAccount } from "@ledgerhq/coin-framework/account/index"; import { AmountRequired, InvalidAddress, @@ -5,9 +6,16 @@ import { NotEnoughBalance, RecipientRequired, } from "@ledgerhq/errors"; -import { Account, AccountBridge, SubAccount } from "@ledgerhq/types-live"; +import { Account, AccountBridge } from "@ledgerhq/types-live"; +import { toNano } from "@ton/core"; import BigNumber from "bignumber.js"; -import { TonCommentInvalid } from "./errors"; +import { MINIMUM_REQUIRED_BALANCE, TOKEN_TRANSFER_MAX_FEE } from "./constants"; +import { + TonCommentInvalid, + TonExcessFee, + TonMinimumRequired, + TonNotEnoughBalanceInParentAccount, +} from "./errors"; import { Transaction, TransactionStatus } from "./types"; import { addressesAreEqual, commentIsValid, isAddressValid } from "./utils"; @@ -60,21 +68,40 @@ const validateSender = (account: Account): Array => { }; const validateAmount = ( - account: Account | SubAccount, + account: Account, transaction: Transaction, totalSpent: BigNumber, ): Array => { const errors: ValidationIssues = {}; const warnings: ValidationIssues = {}; + const subAccount = findSubAccountById(account, transaction.subAccountId ?? ""); + const tokenTransfer = Boolean(subAccount && isTokenAccount(subAccount)); + // if no amount or 0 if (!transaction.amount || transaction.amount.isZero()) { errors.amount = new AmountRequired(); // "Amount required" - } else if (totalSpent.isGreaterThan(account.balance)) { + } else if ( + totalSpent.isGreaterThan( + tokenTransfer && subAccount ? subAccount?.spendableBalance : account.balance, + ) + ) { // if not enough to make the transaction errors.amount = new NotEnoughBalance(); // "Sorry, insufficient funds" } + if (tokenTransfer) { + if (account.balance.isLessThan(new BigNumber(toNano(TOKEN_TRANSFER_MAX_FEE).toString()))) { + // if not enough for the fee to make the transaction + errors.amount = new TonNotEnoughBalanceInParentAccount(); // "Sorry, insufficient funds in the parent account" + } + warnings.amount = new TonExcessFee(); + } else { + if (account.balance.isLessThan(new BigNumber(toNano(MINIMUM_REQUIRED_BALANCE).toString()))) { + errors.amount = new TonMinimumRequired(); + } + } + return [errors, warnings]; }; @@ -83,6 +110,9 @@ const validateComment = (transaction: Transaction): Array => { // if the comment isn'transaction encrypted, it should be valid if (transaction.comment.isEncrypted || !commentIsValid(transaction.comment)) { + // We use transaction as an error here. + // It will be usefull to block a memo wrong format + // on the ledger-live mobile errors.transaction = new TonCommentInvalid(); } return [errors]; @@ -96,7 +126,9 @@ export const getTransactionStatus: AccountBridge< account: Account, transaction: Transaction, ): Promise => { - const totalSpent = transaction.amount.plus(transaction.fees); + const subAccount = findSubAccountById(account, transaction.subAccountId ?? ""); + const tokenTransfer = Boolean(subAccount && isTokenAccount(subAccount)); + const totalSpent = tokenTransfer ? transaction.amount : transaction.amount.plus(transaction.fees); // Recipient related errors and warnings const [recipientErr] = validateRecipient(account, transaction); diff --git a/libs/coin-modules/coin-ton/src/logic.ts b/libs/coin-modules/coin-ton/src/logic.ts new file mode 100644 index 000000000000..7b4342e68f3f --- /dev/null +++ b/libs/coin-modules/coin-ton/src/logic.ts @@ -0,0 +1,18 @@ +import { listTokensForCryptoCurrency } from "@ledgerhq/cryptoassets"; +import { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; +import murmurhash from "imurmurhash"; + +const simpleSyncHashMemoize: Record = {}; +export function getSyncHash(currency: CryptoCurrency, blacklistedList: string[]): string { + const tokens = listTokensForCryptoCurrency(currency).filter( + token => !blacklistedList.includes(token.id), + ); + const stringToHash = tokens + .map(token => token.id + token.contractAddress + token.name + token.ticker + token.units) + .join(""); + + if (!simpleSyncHashMemoize[stringToHash]) { + simpleSyncHashMemoize[stringToHash] = `0x${murmurhash(stringToHash).result().toString(16)}`; + } + return simpleSyncHashMemoize[stringToHash]; +} diff --git a/libs/coin-modules/coin-ton/src/prepareTransaction.ts b/libs/coin-modules/coin-ton/src/prepareTransaction.ts index 86855f39c2b9..ec26e4b354a0 100644 --- a/libs/coin-modules/coin-ton/src/prepareTransaction.ts +++ b/libs/coin-modules/coin-ton/src/prepareTransaction.ts @@ -1,3 +1,4 @@ +import { findSubAccountById } from "@ledgerhq/coin-framework/account/index"; import { defaultUpdateTransaction } from "@ledgerhq/coin-framework/bridge/jsHelpers"; import { Account, AccountBridge } from "@ledgerhq/types-live"; import { fetchAccountInfo } from "./bridge/bridgeHelpers/api"; @@ -9,14 +10,15 @@ const prepareTransaction: AccountBridge["prepareTransactio transaction: Transaction, ): Promise => { const accountInfo = await fetchAccountInfo(account.freshAddress); + const subAccount = findSubAccountById(account, transaction.subAccountId ?? ""); - const simpleTx = buildTonTransaction(transaction, accountInfo.seqno); + const simpleTx = buildTonTransaction(transaction, accountInfo.seqno, account); const fees = await getTonEstimatedFees(account, accountInfo.status === "uninit", simpleTx); let amount; if (transaction.useAllAmount) { - amount = account.spendableBalance.minus(fees); + amount = subAccount ? subAccount.spendableBalance : account.spendableBalance.minus(fees); } else { amount = transaction.amount; } diff --git a/libs/coin-modules/coin-ton/src/signOperation.ts b/libs/coin-modules/coin-ton/src/signOperation.ts index b25868f4310d..944327b0f373 100644 --- a/libs/coin-modules/coin-ton/src/signOperation.ts +++ b/libs/coin-modules/coin-ton/src/signOperation.ts @@ -1,9 +1,12 @@ +import { findSubAccountById, isTokenAccount } from "@ledgerhq/coin-framework/account/index"; import { SignerContext } from "@ledgerhq/coin-framework/signer"; import type { Account, AccountBridge, DeviceId, SignOperationEvent } from "@ledgerhq/types-live"; -import { Address, beginCell, external, storeMessage } from "@ton/core"; +import { Address, beginCell, external, storeMessage, toNano } from "@ton/core"; import { WalletContractV4 } from "@ton/ton"; +import BigNumber from "bignumber.js"; import { Observable } from "rxjs"; import { fetchAccountInfo } from "./bridge/bridgeHelpers/api"; +import { TOKEN_TRANSFER_MAX_FEE } from "./constants"; import type { TonSigner } from "./signer"; import type { TonCell, TonOperation, Transaction } from "./types"; import { buildTonTransaction, getLedgerTonPath } from "./utils"; @@ -43,7 +46,7 @@ export const buildSignOperation = const address = account.freshAddress; const accountInfo = await fetchAccountInfo(address); - const tonTx = buildTonTransaction(transaction, accountInfo.seqno); + const tonTx = buildTonTransaction(transaction, accountInfo.seqno, account); const ledgerPath = getLedgerTonPath(account.freshAddressPath); @@ -87,9 +90,15 @@ export const buildOptimisticOperation = ( account: Account, transaction: Transaction, ): TonOperation => { - const { recipient, amount, fees, comment } = transaction; + const { recipient, amount, fees, comment, useAllAmount, subAccountId } = transaction; const { id: accountId } = account; + const subAccount = findSubAccountById(account, subAccountId ?? ""); + const tokenTransfer = Boolean(subAccount && isTokenAccount(subAccount)); + const value = tokenTransfer + ? BigNumber(toNano(TOKEN_TRANSFER_MAX_FEE).toString()) + : amount.plus(fees); + const op: TonOperation = { id: "", hash: "", @@ -97,7 +106,7 @@ export const buildOptimisticOperation = ( senders: [account.freshAddress], recipients: [recipient], accountId, - value: amount.plus(fees), + value, fee: fees, blockHash: null, blockHeight: null, @@ -106,9 +115,33 @@ export const buildOptimisticOperation = ( // we don't know yet, will be patched in final operation lt: "", explorerHash: "", - comment: comment, + comment, }, }; + if (tokenTransfer && subAccount) { + op.subOperations = [ + { + id: "", + hash: "", + type: "OUT", + value: useAllAmount ? subAccount.balance : amount, + fee: fees, + blockHash: null, + blockHeight: null, + senders: [account.freshAddress], + recipients: [recipient], + accountId: subAccount.id, + date: new Date(), + extra: { + lt: "", + explorerHash: "", + comment, + }, + contract: subAccount.token.contractAddress, + }, + ]; + } + return op; }; diff --git a/libs/coin-modules/coin-ton/src/specs.ts b/libs/coin-modules/coin-ton/src/specs.ts index aba0ea27bb5b..8f57116d2175 100644 --- a/libs/coin-modules/coin-ton/src/specs.ts +++ b/libs/coin-modules/coin-ton/src/specs.ts @@ -2,11 +2,13 @@ import { botTest, pickSiblings } from "@ledgerhq/coin-framework/bot/specs"; import type { AppSpec, TransactionDestinationTestInput } from "@ledgerhq/coin-framework/bot/types"; import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets"; import { DeviceModelId } from "@ledgerhq/devices"; +import { Account } from "@ledgerhq/types-live"; import BigNumber from "bignumber.js"; import expect from "expect"; import invariant from "invariant"; -import { acceptTransaction } from "./speculos-deviceActions"; +import { generateDeviceActionFlow } from "./speculos-deviceActions"; import { Transaction } from "./types"; +import { BotScenario } from "./utils"; const MIN_SAFE = new BigNumber(1.5e7); // approx two txs' fees (0.015 TON) @@ -15,7 +17,7 @@ export const testDestination = ({ destinationBeforeTransaction, sendingOperation, }: TransactionDestinationTestInput): void => { - const amount = sendingOperation.value.minus(sendingOperation.fee); + const amount = sendingOperation.value; const inOp = destination.operations.find( op => op.hash === sendingOperation.hash && op.type === "IN", ); @@ -40,10 +42,10 @@ const tonSpecs: AppSpec = { name: "TON", currency: getCryptoCurrencyById("ton"), appQuery: { - model: DeviceModelId.nanoS, + model: DeviceModelId.nanoSP, appName: "TON", }, - genericDeviceAction: acceptTransaction, + genericDeviceAction: generateDeviceActionFlow(BotScenario.DEFAULT), testTimeout: 6 * 60 * 1000, minViableAmount: MIN_SAFE, transactionCheck: ({ maxSpendable }) => { @@ -60,8 +62,8 @@ const tonSpecs: AppSpec = { const updates: Array> = [ { recipient: pickSiblings(siblings).freshAddress }, { amount: maxSpendable.div(2).integerValue() }, + { comment: { isEncrypted: false, text: "LL Bot" } }, ]; - if (Math.random() < 0.5) updates.push({ comment: { isEncrypted: false, text: "LL Bot" } }); return { transaction: bridge.createTransaction(account), @@ -93,8 +95,8 @@ const tonSpecs: AppSpec = { const updates: Array> = [ { recipient: pickSiblings(siblings).freshAddress }, { useAllAmount: true }, + { comment: { isEncrypted: false, text: "LL Bot" } }, ]; - if (Math.random() < 0.5) updates.push({ comment: { isEncrypted: false, text: "LL Bot" } }); return { transaction: bridge.createTransaction(account), @@ -114,6 +116,53 @@ const tonSpecs: AppSpec = { ); }, }, + { + name: "Send ~50% jUSDT", + maxRun: 1, + deviceAction: generateDeviceActionFlow(BotScenario.TOKEN_TRANSFER), + transaction: ({ account, bridge, maxSpendable, siblings }) => { + invariant(maxSpendable.gt(0), "Spendable balance is too low"); + const subAccount = account.subAccounts?.find( + a => a.type === "TokenAccount" && a.spendableBalance.gt(0), + ); + const recipient = (siblings[0] as Account).freshAddress; + invariant(subAccount && subAccount.type === "TokenAccount", "no subAccount with jUSDT"); + const amount = subAccount.balance.div(1.9 + 0.2 * Math.random()).integerValue(); + const updates: Array> = [ + { + subAccountId: subAccount.id, + }, + { + recipient, + }, + { + amount, + }, + ]; + if (Math.random() < 0.5) updates.push({ comment: { isEncrypted: false, text: "LL Bot" } }); + return { + transaction: bridge.createTransaction(account), + updates, + }; + }, + test: ({ account, accountBeforeTransaction, operation, transaction, status }) => { + const subAccountId = transaction.subAccountId; + const subAccount = account.subAccounts?.find(sa => sa.id === subAccountId); + const subAccountBeforeTransaction = accountBeforeTransaction.subAccounts?.find( + sa => sa.id === subAccountId, + ); + botTest("subAccount balance moved with the tx status amount", () => + expect(subAccount?.balance.toString()).toBe( + subAccountBeforeTransaction?.balance.minus(status.amount).toString(), + ), + ); + botTest("operation comment", () => + expect(operation.extra).toMatchObject({ + comment: transaction.comment, + }), + ); + }, + }, ], }; diff --git a/libs/coin-modules/coin-ton/src/speculos-deviceActions.ts b/libs/coin-modules/coin-ton/src/speculos-deviceActions.ts index 586b926c6f45..0102ce76737c 100644 --- a/libs/coin-modules/coin-ton/src/speculos-deviceActions.ts +++ b/libs/coin-modules/coin-ton/src/speculos-deviceActions.ts @@ -4,35 +4,83 @@ import { formatDeviceAmount, } from "@ledgerhq/coin-framework/bot/specs"; import type { DeviceAction, State } from "@ledgerhq/coin-framework/bot/types"; +import { TOKEN_TRANSFER_MAX_FEE } from "./constants"; import type { Transaction } from "./types"; +import { BotScenario } from "./utils"; -export const acceptTransaction: DeviceAction> = deviceActionFlow({ - steps: [ - { - title: "Review", - button: SpeculosButton.RIGHT, - }, - { - title: "To", - button: SpeculosButton.RIGHT, - expectedValue: ({ transaction }) => transaction.recipient, - }, - { - title: "Amount", - button: SpeculosButton.RIGHT, - expectedValue: ({ account, transaction }) => - transaction.useAllAmount - ? "ALL YOUR TONs" - : formatDeviceAmount(account.currency, transaction.amount), - }, - { - title: "Comment", - button: SpeculosButton.RIGHT, - expectedValue: ({ transaction }) => transaction.comment.text, - }, - { - title: "Approve", - button: SpeculosButton.BOTH, - }, - ], -}); +export const generateDeviceActionFlow = ( + scenario: BotScenario, +): DeviceAction> => { + const data: Parameters>[0] = { + steps: [ + { + title: "Review", + button: SpeculosButton.RIGHT, + }, + ], + }; + + if (scenario === "token-transfer") { + data.steps = data.steps.concat([ + { + title: "Transfer jetton", + button: SpeculosButton.RIGHT, + }, + { + title: "Jetton wallet (1/2)", + button: SpeculosButton.RIGHT, + }, + { + title: "Jetton wallet (2/2)", + button: SpeculosButton.RIGHT, + }, + { + title: "Amount", + button: SpeculosButton.RIGHT, + expectedValue: () => `TON ${TOKEN_TRANSFER_MAX_FEE}`, + }, + { + title: "Jetton units", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => transaction.amount.toString(), + }, + { + title: "Send jetton to (1/2)", + button: SpeculosButton.RIGHT, + }, + { + title: "Send jetton to (2/2)", + button: SpeculosButton.RIGHT, + }, + { + title: "Approve", + button: SpeculosButton.BOTH, + }, + ]); + } else { + data.steps = data.steps.concat([ + { + title: "To", + button: SpeculosButton.RIGHT, + }, + { + title: "Amount", + button: SpeculosButton.RIGHT, + expectedValue: ({ account, transaction }) => + transaction.useAllAmount + ? "ALL YOUR TONs" + : formatDeviceAmount(account.currency, transaction.amount), + }, + { + title: "Comment", + button: SpeculosButton.RIGHT, + expectedValue: ({ transaction }) => transaction.comment.text, + }, + { + title: "Approve", + button: SpeculosButton.BOTH, + }, + ]); + } + return deviceActionFlow(data); +}; diff --git a/libs/coin-modules/coin-ton/src/synchronisation.ts b/libs/coin-modules/coin-ton/src/synchronisation.ts index d91e15558143..71f78ebf8bf0 100644 --- a/libs/coin-modules/coin-ton/src/synchronisation.ts +++ b/libs/coin-modules/coin-ton/src/synchronisation.ts @@ -1,15 +1,38 @@ -import { decodeAccountId, encodeAccountId } from "@ledgerhq/coin-framework/account/index"; -import { GetAccountShape, makeSync, mergeOps } from "@ledgerhq/coin-framework/bridge/jsHelpers"; +import { + decodeAccountId, + decodeTokenAccountId, + emptyHistoryCache, + encodeAccountId, + encodeTokenAccountId, +} from "@ledgerhq/coin-framework/account/index"; +import { + AccountShapeInfo, + GetAccountShape, + makeSync, + mergeOps, +} from "@ledgerhq/coin-framework/bridge/jsHelpers"; +import { decodeOperationId } from "@ledgerhq/coin-framework/operation"; import { log } from "@ledgerhq/logs"; -import { Account } from "@ledgerhq/types-live"; +import { TokenCurrency } from "@ledgerhq/types-cryptoassets"; +import { Account, SubAccount } from "@ledgerhq/types-live"; import BigNumber from "bignumber.js"; import flatMap from "lodash/flatMap"; -import { fetchAccountInfo, fetchLastBlockNumber } from "./bridge/bridgeHelpers/api"; -import { TonTransactionsList } from "./bridge/bridgeHelpers/api.types"; -import { getTransactions, mapTxToOps } from "./bridge/bridgeHelpers/txn"; +import { + fetchAccountInfo, + fetchJettonWallets, + fetchLastBlockNumber, +} from "./bridge/bridgeHelpers/api"; +import { TonJettonTransfer, TonTransactionsList } from "./bridge/bridgeHelpers/api.types"; +import { + getJettonTransfers, + getTransactions, + mapJettonTxToOps, + mapTxToOps, +} from "./bridge/bridgeHelpers/txn"; +import { getSyncHash } from "./logic"; import { TonOperation } from "./types"; -export const getAccountShape: GetAccountShape = async info => { +export const getAccountShape: GetAccountShape = async (info, { blacklistedTokenIds }) => { const { address, rest, currency, derivationMode, initialAccount } = info; const publicKey = reconciliatePubkey(rest?.publicKey, initialAccount); @@ -24,28 +47,51 @@ export const getAccountShape: GetAccountShape = async info => { }); log("debug", `Generation account shape for ${address}`); + const syncHash = getSyncHash(currency, blacklistedTokenIds ?? []); + const shouldSyncFromScratch = syncHash !== initialAccount?.syncHash; const newTxs: TonTransactionsList = { transactions: [], address_book: {} }; + const newJettonTxs: TonJettonTransfer[] = []; const oldOps = (initialAccount?.operations ?? []) as TonOperation[]; const { last_transaction_lt, balance } = await fetchAccountInfo(address); - // if last_transaction_lt is empty, then there are no transactions in account + // if last_transaction_lt is empty, then there are no transactions in account (as well in token accounts) if (last_transaction_lt != null) { - if (oldOps.length === 0) { - const [tmpTxs] = await Promise.all([getTransactions(address)]); + if (oldOps.length === 0 || shouldSyncFromScratch) { + const [tmpTxs, tmpJettonTxs] = await Promise.all([ + getTransactions(address), + getJettonTransfers(address), + ]); + newTxs.transactions.push(...tmpTxs.transactions); newTxs.address_book = { ...newTxs.address_book, ...tmpTxs.address_book }; + newJettonTxs.push(...tmpJettonTxs); } else { - // if they are the same, we have no new ops + // if they are the same, we have no new ops (including tokens) if (oldOps[0].extra.lt !== last_transaction_lt) { - const [tmpTxs] = await Promise.all([getTransactions(address, oldOps[0].extra.lt)]); + const [tmpTxs, tmpJettonTxs] = await Promise.all([ + getTransactions(address, oldOps[0].extra.lt), + getJettonTransfers(address, oldOps[0].extra.lt), + ]); newTxs.transactions.push(...tmpTxs.transactions); newTxs.address_book = { ...newTxs.address_book, ...tmpTxs.address_book }; + newJettonTxs.push(...tmpJettonTxs); } } } const newOps = flatMap(newTxs.transactions, mapTxToOps(accountId, address, newTxs.address_book)); - const operations = mergeOps(oldOps, newOps); + const newJettonOps = flatMap( + newJettonTxs, + mapJettonTxToOps(accountId, address, newTxs.address_book), + ); + const operations = shouldSyncFromScratch ? newOps : mergeOps(oldOps, newOps); + const subAccounts = await getSubAccounts( + info, + accountId, + newJettonOps, + blacklistedTokenIds, + shouldSyncFromScratch, + ); const toReturn = { id: accountId, @@ -53,6 +99,7 @@ export const getAccountShape: GetAccountShape = async info => { spendableBalance: new BigNumber(balance), operations, operationsCount: operations.length, + subAccounts, blockHeight, xpub: publicKey, lastSyncDate: new Date(), @@ -60,7 +107,74 @@ export const getAccountShape: GetAccountShape = async info => { return toReturn; }; +const getSubAccountShape = async ( + info: AccountShapeInfo, + parentId: string, + token: TokenCurrency, + ops: TonOperation[], + shouldSyncFromScratch: boolean, +): Promise> => { + const tokenAccountId = encodeTokenAccountId(parentId, token); + const walletsInfo = await fetchJettonWallets({ + address: info.address, + jettonMaster: token.contractAddress, + }); + if (walletsInfo.length !== 1) throw new Error("[ton] unexpected api response"); + const { balance, address: jettonWalletAddress } = walletsInfo[0]; + const oldOps = info.initialAccount?.subAccounts?.find(a => a.id === tokenAccountId)?.operations; + const operations = !oldOps || shouldSyncFromScratch ? ops : mergeOps(oldOps, ops); + const maybeExistingSubAccount = + info.initialAccount && + info.initialAccount.subAccounts && + info.initialAccount.subAccounts.find(a => a.id === tokenAccountId); + + return { + type: "TokenAccount", + id: tokenAccountId, + parentId, + token: { ...token, contractAddress: jettonWalletAddress }, // the contract address is replaced for the jetton wallet address, it will be use for the token transfer + balance: new BigNumber(balance), + spendableBalance: new BigNumber(balance), + operations, + operationsCount: operations.length, + pendingOperations: maybeExistingSubAccount ? maybeExistingSubAccount.pendingOperations : [], + creationDate: operations.length > 0 ? operations[operations.length - 1].date : new Date(), + balanceHistoryCache: emptyHistoryCache, // calculated in the jsHelpers + swapHistory: [], + }; +}; + +async function getSubAccounts( + info: AccountShapeInfo, + accountId: string, + newOps: TonOperation[], + blacklistedTokenIds: string[] = [], + shouldSyncFromScratch: boolean, +): Promise[]> { + const opsPerToken = newOps.reduce((acc, op) => { + const { accountId: tokenAccountId } = decodeOperationId(op.id); + const { token } = decodeTokenAccountId(tokenAccountId); + if (!token || blacklistedTokenIds.includes(token.id)) return acc; + if (!acc.has(token)) acc.set(token, []); + acc.get(token)?.push(op); + return acc; + }, new Map()); + const subAccountsPromises: Promise>[] = []; + for (const [token, ops] of opsPerToken.entries()) { + subAccountsPromises.push( + getSubAccountShape(info, accountId, token, ops, shouldSyncFromScratch), + ); + } + return Promise.all(subAccountsPromises); +} + const postSync = (initial: Account, synced: Account): Account => { + // Set of ids from the already existing subAccount from previous sync + const initialSubAccountsIds = new Set(); + for (const subAccount of initial.subAccounts || []) { + initialSubAccountsIds.add(subAccount.id); + } + const initialPendingOperations = initial.pendingOperations || []; const { operations } = synced; const pendingOperations = initialPendingOperations.filter( @@ -75,6 +189,20 @@ const postSync = (initial: Account, synced: Account): Account => { return { ...synced, pendingOperations, + subAccounts: synced.subAccounts?.map(subAccount => { + // If the subAccount is new, just return the freshly synced subAccount + if (!initialSubAccountsIds.has(subAccount.id)) return subAccount; + return { + ...subAccount, + pendingOperations: subAccount.pendingOperations.filter( + tokenPendingOperation => + // if the pending operation got removed from the main account, remove it as well + coinPendingOperationsHashes.has(tokenPendingOperation.hash) && + // if the transaction has been confirmed, remove it + !subAccount.operations.some(op => op.id === tokenPendingOperation.id), + ), + }; + }), }; }; diff --git a/libs/coin-modules/coin-ton/src/utils.ts b/libs/coin-modules/coin-ton/src/utils.ts index 2b9c4b693b11..16a16e8642ca 100644 --- a/libs/coin-modules/coin-ton/src/utils.ts +++ b/libs/coin-modules/coin-ton/src/utils.ts @@ -1,10 +1,34 @@ -import { decodeAccountId } from "@ledgerhq/coin-framework/account/index"; +import { decodeAccountId, findSubAccountById } from "@ledgerhq/coin-framework/account/index"; import { Account } from "@ledgerhq/types-live"; -import { SendMode, Address as TonAddress, WalletContractV4, comment, internal } from "@ton/ton"; +import { + Builder, + SendMode, + Address as TonAddress, + WalletContractV4, + comment, + internal, + toNano, +} from "@ton/ton"; import BigNumber from "bignumber.js"; import { estimateFee } from "./bridge/bridgeHelpers/api"; -import { TonComment, TonTransaction, Transaction } from "./types"; +import { + JettonOpCode, + MAX_COMMENT_BYTES, + TOKEN_TRANSFER_FORWARD_AMOUNT, + TOKEN_TRANSFER_MAX_FEE, + TOKEN_TRANSFER_QUERY_ID, +} from "./constants"; +import { + TonCell, + TonComment, + TonPayloadJettonTransfer, + TonTransaction, + Transaction, +} from "./types"; +/** + * Checks if the given recipient address is valid. + */ export const isAddressValid = (recipient: string) => { try { return Boolean( @@ -16,6 +40,9 @@ export const isAddressValid = (recipient: string) => { } }; +/** + * Compares two addresses to check if they are equal. + */ export const addressesAreEqual = (addr1: string, addr2: string) => { try { return ( @@ -28,8 +55,15 @@ export const addressesAreEqual = (addr1: string, addr2: string) => { } }; -export const buildTonTransaction = (transaction: Transaction, seqno: number): TonTransaction => { - const { useAllAmount, amount, comment, recipient } = transaction; +/** + * Builds a TonTransaction object based on the given transaction details. + */ +export const buildTonTransaction = ( + transaction: Transaction, + seqno: number, + account: Account, +): TonTransaction => { + const { subAccountId, useAllAmount, amount, comment: commentTx, recipient } = transaction; let recipientParsed = recipient; // if recipient is not valid calculate fees with empty address // we handle invalid addresses in account bridge @@ -39,35 +73,64 @@ export const buildTonTransaction = (transaction: Transaction, seqno: number): To recipientParsed = new TonAddress(0, Buffer.alloc(32)).toRawString(); } - const finalAmount = useAllAmount ? BigInt(0) : BigInt(amount.toFixed()); + // if there is a sub account, the transaction is a token transfer + const subAccount = findSubAccountById(account, subAccountId ?? ""); + + const finalAmount = subAccount + ? toNano(TOKEN_TRANSFER_MAX_FEE) // for commission fees, excess will be returned + : useAllAmount + ? BigInt(0) + : BigInt(amount.toFixed()); + const to = subAccount ? subAccount.token.contractAddress : recipientParsed; const tonTransaction: TonTransaction = { - to: TonAddress.parse(recipientParsed), + to: TonAddress.parse(to), seqno, amount: finalAmount, - bounce: TonAddress.isFriendly(recipientParsed) - ? TonAddress.parseFriendly(recipientParsed).isBounceable - : true, + bounce: TonAddress.isFriendly(to) ? TonAddress.parseFriendly(to).isBounceable : true, timeout: getTransferExpirationTime(), - sendMode: useAllAmount - ? SendMode.CARRY_ALL_REMAINING_BALANCE - : SendMode.IGNORE_ERRORS + SendMode.PAY_GAS_SEPARATELY, + sendMode: + useAllAmount && !subAccount + ? SendMode.CARRY_ALL_REMAINING_BALANCE + : SendMode.IGNORE_ERRORS + SendMode.PAY_GAS_SEPARATELY, }; - if (comment.text.length) { - tonTransaction.payload = { type: "comment", text: comment.text }; + if (commentTx.text.length) { + tonTransaction.payload = { type: "comment", text: commentTx.text }; + } + + if (subAccount) { + const forwardPayload = commentTx.text.length ? comment(commentTx.text) : null; + + tonTransaction.payload = { + type: "jetton-transfer", + queryId: BigInt(TOKEN_TRANSFER_QUERY_ID), + amount: BigInt(amount.toFixed()), + destination: TonAddress.parse(recipientParsed), + responseDestination: TonAddress.parse(account.freshAddress), + customPayload: null, + forwardAmount: BigInt(TOKEN_TRANSFER_FORWARD_AMOUNT), + forwardPayload, + }; } return tonTransaction; }; -// max length is 120 and only ascii allowed +/** + * Validates if the given comment is valid. + */ export const commentIsValid = (msg: TonComment) => - !msg.isEncrypted && msg.text.length <= 120 && /^[\x20-\x7F]*$/.test(msg.text); + !msg.isEncrypted && msg.text.length <= MAX_COMMENT_BYTES && /^[\x20-\x7F]*$/.test(msg.text); -// 1 minute +/** + * Gets the transfer expiration time. + */ export const getTransferExpirationTime = () => Math.floor(Date.now() / 1000 + 60); +/** + * Estimates the fees for a Ton transaction. + */ export const getTonEstimatedFees = async ( account: Account, needsInit: boolean, @@ -75,8 +138,18 @@ export const getTonEstimatedFees = async ( ) => { const { xpubOrAddress: pubKey } = decodeAccountId(account.id); if (pubKey.length !== 64) throw Error("[ton] pubKey can't be found"); - if (tx.payload && tx.payload?.type !== "comment") { - throw Error("[ton] payload kind not expected"); + + // build body depending the payload type + let body: TonCell | undefined; + if (tx.payload) { + switch (tx.payload.type) { + case "comment": + body = comment(tx.payload.text); + break; + case "jetton-transfer": + body = buildTokenTransferBody(tx.payload); + break; + } } const contract = WalletContractV4.create({ workchain: 0, publicKey: Buffer.from(pubKey, "hex") }); const transfer = contract.createTransfer({ @@ -87,7 +160,7 @@ export const getTonEstimatedFees = async ( bounce: tx.bounce, to: tx.to, value: tx.amount, - body: tx.payload?.text ? comment(tx.payload.text) : undefined, + body, }), ], sendMode: tx.sendMode, @@ -103,6 +176,9 @@ export const getTonEstimatedFees = async ( return BigNumber(fee.fwd_fee + fee.gas_fee + fee.in_fwd_fee + fee.storage_fee); }; +/** + * Converts a Ledger path string to an array of numbers.length. + */ export const getLedgerTonPath = (path: string): number[] => { const numPath: number[] = []; if (!path) throw Error("[ton] Path is empty"); @@ -118,3 +194,102 @@ export const getLedgerTonPath = (path: string): number[] => { } return numPath; }; + +/** + * Builds the body of a token transfer transaction. + */ +function buildTokenTransferBody(params: TonPayloadJettonTransfer): TonCell { + const { queryId, amount, destination, responseDestination, forwardAmount } = params; + let forwardPayload = params.forwardPayload; + + let builder = new Builder() + .storeUint(JettonOpCode.Transfer, 32) + .storeUint(queryId ?? generateQueryId(), 64) + .storeCoins(amount) + .storeAddress(destination) + .storeAddress(responseDestination) + .storeBit(false) + .storeCoins(forwardAmount ?? BigInt(0)); + + if (forwardPayload instanceof Uint8Array) { + forwardPayload = packBytesAsSnake(forwardPayload); + } + + if (!forwardPayload) { + builder.storeBit(false); + } else if (typeof forwardPayload === "string") { + builder = builder.storeBit(false).storeUint(0, 32).storeBuffer(Buffer.from(forwardPayload)); + } else if (forwardPayload instanceof Uint8Array) { + builder = builder.storeBit(false).storeBuffer(Buffer.from(forwardPayload)); + } else { + builder = builder.storeBit(true).storeRef(forwardPayload); + } + + return builder.endCell(); +} + +/** + * Generates a random BigInt of the specified byte length. + */ +function bigintRandom(bytes: number) { + let value = BigInt(0); + for (const randomNumber of randomBytes(bytes)) { + const randomBigInt = BigInt(randomNumber); + value = (value << BigInt(8)) + randomBigInt; + } + return value; +} + +/** + * Generates a random byte array of the specified size. + */ +function randomBytes(size: number) { + return self.crypto.getRandomValues(new Uint8Array(size)); +} + +/** + * Generates a random query ID. + */ +function generateQueryId() { + return bigintRandom(8); +} + +/** + * Packs a byte array into a TonCell using a snake-like structure. + */ +function packBytesAsSnake(bytes: Uint8Array): TonCell { + return packBytesAsSnakeCell(bytes); +} + +/** + * Packs a byte array into a TonCell using a snake-like structure. + */ +function packBytesAsSnakeCell(bytes: Uint8Array): TonCell { + const buffer = Buffer.from(bytes); + + const mainBuilder = new Builder(); + let prevBuilder: Builder | undefined; + let currentBuilder = mainBuilder; + + for (const [i, byte] of buffer.entries()) { + if (currentBuilder.availableBits < 8) { + prevBuilder?.storeRef(currentBuilder); + + prevBuilder = currentBuilder; + currentBuilder = new Builder(); + } + + currentBuilder = currentBuilder.storeUint(byte, 8); + + if (i === buffer.length - 1) { + prevBuilder?.storeRef(currentBuilder); + } + } + + return mainBuilder.asCell(); +} + +export enum BotScenario { + DEFAULT = "default", + TOKEN_TRANSFER = "token-transfer", +} diff --git a/libs/ledger-live-common/src/currencies/__snapshots__/sortByMarketcap.test.ts.snap b/libs/ledger-live-common/src/currencies/__snapshots__/sortByMarketcap.test.ts.snap index 2ec1816a5d98..a1dc02a6763b 100644 --- a/libs/ledger-live-common/src/currencies/__snapshots__/sortByMarketcap.test.ts.snap +++ b/libs/ledger-live-common/src/currencies/__snapshots__/sortByMarketcap.test.ts.snap @@ -1036,6 +1036,7 @@ exports[`sortCurrenciesByIds snapshot 1`] = ` "ethereum/erc20/paymon", "ethereum/erc20/inmax", "ethereum/erc20/notional", + "ton/jetton/eqdv-yr41_cz2urg2gfegvfa44pdpjik9f-miledkduihlwz", "ethereum/erc20/lala_world_token", "ethereum/erc20/rebootworld", "ethereum/erc20/superfarm", @@ -15305,5 +15306,51 @@ exports[`sortCurrenciesByIds snapshot 1`] = ` "cardano/native/f7c777fdd4531cf1c477551360e45b9684073c05c2fa61334f8f9add5665726974726565546f6b656e", "stellar/asset/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "casper/asset/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + "ton/jetton/eqcxe6mutqjkfngfarotkot1lzbdiix1kcixrv7nw2id_sds", + "ton/jetton/eqaqxlwjvgbbffe8f3os8s87ligdovs455iswfardmjetton", + "ton/jetton/eqcvaf0jmrv6bovppagee08uqm_urpud__fha7nm8twzvbe_", + "ton/jetton/eqdcjl0iqhofcbbvfbhdvg233ri2v4kcnfgfrt-gqad3oc86", + "ton/jetton/eqblqsm144dq6sjbpi4jjzva1hqtip3cvhovbifw_t-scale", + "ton/jetton/eqa2kcvnwvsil2em2mb0skxytxcqqjs4mttjdpnxmwg9t6bo", + "ton/jetton/eqcdpz6qhjtdtm2s9-krv2ygl45pwl-kjjcv1-xrp-xuuxoq", + "ton/jetton/eqcbdxpecfeph2wuxi1a6qioksf-5qdjuwqlcuuktd-glint", + "ton/jetton/eqcjbp0kbppwpobg-u5c-cwfp_jnksvotgfarpf50q9qiv9h", + "ton/jetton/eqdnhy-nxyfguqzfuzimbep67jqsymicyk2s5_rwnneyku0k", + "ton/jetton/eqc98_qamneptutpc7w6xdhh_zhrbufpw5ft_iznu20qajav", + "ton/jetton/eqb0soxugdx5qjvt0p_bpicfewdflbmvophhjgfs0q-wston", + "ton/jetton/eqbno5qag8i8j6ixgaz15sfqvb-kx98yhkv_mt36xo5vyxua", + "ton/jetton/eqcbkmtmeadsnzsk85lopadkdh3hjujebtepmseirveanq-u", + "ton/jetton/eqbjoj2el_cuft_0r9meoqjkuwrttc_-nujyvwqxszvwe1wy", + "ton/jetton/eqbx6k9axvl3nxincyppl86c4onvmq8vk360u6dykfkxphca", + "ton/jetton/eqbynbo23ywhy_cgary9nk9ftz0ydsg82ptcbstqggoxwiua", + "ton/jetton/eqb-mpwrd1g6wknklz_vnv6wqbdd142kmqv-g1o-8qua3728", + "ton/jetton/eqdcbkghmc4ptf34x3gm05xvepo5w60dnxz-xt4i6-ugg5l5", + "ton/jetton/eqaeuiklqvh2ldmrv99nthqfl_txeycej1xkmupt60tfvdps", + "ton/jetton/eqc47093ox5xhb0xuk2lcr2rhs8rj-vul61u4w2uh5ormg_o", + "ton/jetton/eqd26zcd6cqpz7wylkvh8x_cd6d7tbrom6hkcycv8l8hv0gp", + "ton/jetton/eqam2kwdp9ln0yvxvfsbi0ryjbxwm70rakpnihbuetatrwa1", + "ton/jetton/eqc-tdrjjoymz3mxkw4pj95bnzgvrywwz23jix3ph7guvhxj", + "ton/jetton/eqd0vdsa_nedr9uvbgn9eikrx-suesdxgefg69xqmavflqiw", + "ton/jetton/eqdndv54v_teu5t26rfykylsdpqsv5nsszah_v7jsjptmitv", + "ton/jetton/eqc8fozmlbczhz6pr9shgyhzkfv9y2b5x9tn61rvuclrzfzz", + "ton/jetton/eqaav0-sgq9biuzgd5sgrnv0z_7s46bvvhqzbuwolnsfckhb", + "ton/jetton/eqbl3gg6aadjgjo2zonu5q5ezuil8xmnzrix8z5djmkhufxi", + "ton/jetton/eqdgsr_-4fdlxpfmvefxx1iixmwbh4yfyv1a_8cj0xsgvdsf", + "ton/jetton/eqatcuc69sgsccmsadsvukdgwm1bmks-hkcwgpk60xzggwsk", + "ton/jetton/eqbtcl4ja-pdpiukb8uthcqdaftmustqdl8z1eexeplti_nk", + "ton/jetton/eqd5ty5ixv3hecey1bbbdd7rnny-zca-paigqxyyrzred9v3", + "ton/jetton/eqbz_cafpydr5kuts0anxh0ztdhkpezonmlja2sngllm4cko", + "ton/jetton/eqctxbfa9bvxn7wsfk5v72fx1rfuc8kfdf7pimiowengebx5", + "ton/jetton/eqc7rnhhtmvbkyhignabtyilzgxs0dfi3zbhexfx0lyi9cah", + "ton/jetton/eqawpz2_g0nkxlg2vvgfbgzgpt8y1qe0cgj-4yw5bfmyr5if", + "ton/jetton/eqal0chjyyngwg0tgmcbm6soekjrktnm_iatf17jcfeqx0ww", + "ton/jetton/eqax9j60va-0widmdqglrmf7imjvg0ytyi3yxnq9y-nbncq2", + "ton/jetton/eqavw-6sk7njepsjgh1gw60lyekhyzsmk9phbxstccldy4bv", + "ton/jetton/eqdrlq8en7a2zstuf7sddoxmlz_wfw0e7eow3u9c4psoe4tg", + "ton/jetton/eqcktemasvybn8dps6lu4qfatjejjrlwd94adqb8ss6etuaa", + "ton/jetton/eqdnjzbnka8ix2x7tv1_jxdcqehpqgjanbisoiksq5srnfls", + "ton/jetton/eqdqz7ltwgj016kitisoom_ft8kvel2p4pj4fkjmsuv_an_x", + "ton/jetton/eqbb-emrejkihvyg5dpiklohwpcscaxjl9hkmgrvugtz_1lu", + "ton/jetton/eqavlwfdxgf2lxm67y4yzc17wykd9a0guwpkms1gosm__not", ] `; diff --git a/libs/ledger-live-common/src/families/ton/__snapshots__/bridge.integration.test.ts.snap b/libs/ledger-live-common/src/families/ton/__snapshots__/bridge.integration.test.ts.snap index 441fa5b3e922..d2bf4a08efc2 100644 --- a/libs/ledger-live-common/src/families/ton/__snapshots__/bridge.integration.test.ts.snap +++ b/libs/ledger-live-common/src/families/ton/__snapshots__/bridge.integration.test.ts.snap @@ -14,11 +14,23 @@ exports[`ton currency bridge scanAccounts ton seed 1 1`] = ` "pendingOperations": [], "seedIdentifier": "86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060", "spendableBalance": "933174896", + "subAccounts": [], "swapHistory": [], "syncHash": undefined, "used": true, "xpub": "86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060", }, + { + "balance": "500000", + "id": "js:2:ton:86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060:ton+ton%2Fjetton%2Feqbynbo23ywhy~!underscore!~cgary9nk9ftz0ydsg82ptcbstqggoxwiua", + "operationsCount": 3, + "parentId": "js:2:ton:86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060:ton", + "pendingOperations": [], + "spendableBalance": "500000", + "swapHistory": [], + "tokenId": "ton/jetton/eqbynbo23ywhy_cgary9nk9ftz0ydsg82ptcbstqggoxwiua", + "type": "TokenAccountRaw", + }, { "balance": "0", "currencyId": "ton", @@ -31,6 +43,7 @@ exports[`ton currency bridge scanAccounts ton seed 1 1`] = ` "pendingOperations": [], "seedIdentifier": "86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060", "spendableBalance": "0", + "subAccounts": [], "swapHistory": [], "syncHash": undefined, "used": false, @@ -193,6 +206,83 @@ exports[`ton currency bridge scanAccounts ton seed 1 2`] = ` "value": "1000000000", }, ], + [ + { + "accountId": "js:2:ton:86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060:ton+ton%2Fjetton%2Feqbynbo23ywhy~!underscore!~cgary9nk9ftz0ydsg82ptcbstqggoxwiua", + "blockHash": null, + "blockHeight": 1, + "extra": { + "comment": { + "isEncrypted": false, + "text": "", + }, + "explorerHash": "Bcr63Sh1uaT08ufACKFaAwCb+XQkGpyLPoghez1J6Fo=", + "lt": "46398662000001", + }, + "fee": "0", + "hasFailed": false, + "hash": "Bcr63Sh1uaT08ufACKFaAwCb+XQkGpyLPoghez1J6Fo=", + "id": "js:2:ton:86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060:ton+ton%2Fjetton%2Feqbynbo23ywhy~!underscore!~cgary9nk9ftz0ydsg82ptcbstqggoxwiua-Bcr63Sh1uaT08ufACKFaAwCb+XQkGpyLPoghez1J6Fo=-OUT", + "recipients": [ + "EQB0Y6BtnRS9rIJXpsTHFzhEF83Xa4e5yBDOrPEOah66rJs_", + ], + "senders": [ + "UQCOvQLYvTcbi5tL9MaDNzuVl3-J3vATimNm9yO5XPafLfV4", + ], + "type": "OUT", + "value": "1000000", + }, + { + "accountId": "js:2:ton:86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060:ton+ton%2Fjetton%2Feqbynbo23ywhy~!underscore!~cgary9nk9ftz0ydsg82ptcbstqggoxwiua", + "blockHash": null, + "blockHeight": 1, + "extra": { + "comment": { + "isEncrypted": false, + "text": "", + }, + "explorerHash": "gSeyxDKNghbKUl4QAawfuaXzIgPTfAiLQ5EUUx1T+cg=", + "lt": "46398624000003", + }, + "fee": "0", + "hasFailed": false, + "hash": "gSeyxDKNghbKUl4QAawfuaXzIgPTfAiLQ5EUUx1T+cg=", + "id": "js:2:ton:86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060:ton+ton%2Fjetton%2Feqbynbo23ywhy~!underscore!~cgary9nk9ftz0ydsg82ptcbstqggoxwiua-gSeyxDKNghbKUl4QAawfuaXzIgPTfAiLQ5EUUx1T+cg=-IN", + "recipients": [ + "UQCOvQLYvTcbi5tL9MaDNzuVl3-J3vATimNm9yO5XPafLfV4", + ], + "senders": [ + "EQB0Y6BtnRS9rIJXpsTHFzhEF83Xa4e5yBDOrPEOah66rJs_", + ], + "type": "IN", + "value": "2000000", + }, + { + "accountId": "js:2:ton:86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060:ton+ton%2Fjetton%2Feqbynbo23ywhy~!underscore!~cgary9nk9ftz0ydsg82ptcbstqggoxwiua", + "blockHash": null, + "blockHeight": 1, + "extra": { + "comment": { + "isEncrypted": false, + "text": "", + }, + "explorerHash": "paL6VAR7c1SWoRKhndZS/l7bA0ffA6d4DPPGcd166TM=", + "lt": "46398739000001", + }, + "fee": "0", + "hasFailed": false, + "hash": "paL6VAR7c1SWoRKhndZS/l7bA0ffA6d4DPPGcd166TM=", + "id": "js:2:ton:86196cb40cd25e9e696bc808e3f2c074ce0b39f2a2a9d482a68eafef86e4a060:ton+ton%2Fjetton%2Feqbynbo23ywhy~!underscore!~cgary9nk9ftz0ydsg82ptcbstqggoxwiua-paL6VAR7c1SWoRKhndZS/l7bA0ffA6d4DPPGcd166TM=-OUT", + "recipients": [ + "EQB0Y6BtnRS9rIJXpsTHFzhEF83Xa4e5yBDOrPEOah66rJs_", + ], + "senders": [ + "UQCOvQLYvTcbi5tL9MaDNzuVl3-J3vATimNm9yO5XPafLfV4", + ], + "type": "OUT", + "value": "500000", + }, + ], [], ] `; diff --git a/libs/ledgerjs/packages/cryptoassets/src/data/jetton.json b/libs/ledgerjs/packages/cryptoassets/src/data/jetton.json new file mode 100644 index 000000000000..8b654ab124e5 --- /dev/null +++ b/libs/ledgerjs/packages/cryptoassets/src/data/jetton.json @@ -0,0 +1,49 @@ +[ + ["EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", "Tether USD", "USD₮", 6, false, true], + ["EQAQXlWJvGbbFfE8F3oS8s87lIgdovS455IsWFaRdmJetTon", "JetTon", "JETTON", 9, false, true], + ["EQCvaf0JMrv6BOvPpAgee08uQM_uRpUd__fhA7Nm8twzvbE_", "TonUP", "UP", 9, false, true], + ["EQDCJL0iQHofcBBvFBHdVG233Ri2V4kCNFgfRT-gqAd3Oc86", "Fanzee Token", "FNZ", 9, false, true], + ["EQBlqsm144Dq6SjbPI4jjZvA1hqTIP3CvHovbIfW_t-SCALE", "SCALE", "SCALE", 9, false, true], + ["EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO", "STON", "STON", 9, false, true], + ["EQCdpz6QhJtDtm2s9-krV2ygl45Pwl-KJJCV1-XrP-Xuuxoq", "$PUNK", "PUNK", 9, false, true], + ["EQCBdxpECfEPH2wUxi1a6QiOkSf-5qDjUWqLCUuKtD-GLINT", "Glint Coin", "GLINT", 9, false, true], + ["EQCJbp0kBpPwPoBG-U5C-cWfP_jnksvotGfArPF50Q9Qiv9h", "Ton Raffles Token", "RAFF", 9, false, true], + ["EQDNhy-nxYFgUqzfUzImBEP67JqsyMIcyk2S5_RwNNEYku0k", "Staked TON", "stTON", 9, false, true], + ["EQC98_qAmNEptUtPc7W6xdHh_ZHrBUFpw5Ft_IzNU20QAJav", "Tonstakers TON", "stTON", 9, false, true], + ["EQB0SoxuGDx5qjVt0P_bPICFeWdFLBmVopHhjgfs0q-wsTON", "Ton Whales liquid staking token", "wsTON", 9, false, true], + ["EQBNo5qAG8I8J6IxGaz15SfQVB-kX98YhKV_mT36Xo5vYxUa", "Hipo Staked TON v1", "hTON1", 9, false, true], + ["EQCbKMTmEAdSnzsK85LOpaDkDH3HjujEbTePMSeirvEaNq-U", "Magic Crystal", "MC", 9, false, true], + ["EQBJOJ2eL_CUFT_0r9meoqjKUwRttC_-NUJyvWQxszVWe1WY", "Petcoin", "PET", 9, false, true], + ["EQBX6K9aXVl3nXINCyPPL86C4ONVmQ8vK360u6dykFKXpHCa", "GEMSTON", "GEMSTON", 9, false, true], + ["EQBynBO23ywHy_CgarY9NK9FTz0yDsG82PtcbSTQgGoXwiuA", "Bridged jUSDT", "jUSDT", 6, false, true], + ["EQB-MPwrd1G6WKNkLz_VnV6WqBDd142KMQv-g1O-8QUA3728", "USD Coin", "jUSDC", 6, false, true], + ["EQDcBkGHmC4pTf34x3Gm05XvepO5w60DNxZ-XT4I6-UGG5L5", "Wrapped BTC", "jWBTC", 8, false, true], + ["EQAEuikLQVh2lDMrV99nTHqFL_TXEyCEJ1xKMuPT60tfvdps", "Mantle", "jMNT", 18, false, true], + ["EQC47093oX5Xhb0xuk2lCr2RhS8rj-vul61u4W2UH5ORmG_O", "Gram", "GRAM", 9, false, true], + ["EQD26zcd6Cqpz7WyLKVH8x_cD6D7tBrom6hKcycv8L8hV0GP", "DeFinder Capital", "DFC", 9, false, true], + ["EQAM2KWDp9lN0YvxvfSbI0ryjBXwM70rakpNIHbuETatRWA1", "ARBUZ", "ARBUZ", 9, false, true], + ["EQC-tdRjjoYMz3MXKW4pj95bNZgvRyWwZ23Jix3ph7guvHxJ", "KINGYTON", "KINGY", 9, false, true], + ["EQD0vdSA_NedR9uvbgN9EikRX-suesDxGeFg69XQMavfLqIw", "Huebel Bolt", "BOLT", 9, false, true], + ["EQDNDv54v_TEU5t26rFykylsdPQsv5nsSZaH_v7JSJPtMitv", "Tonnel Network Token", "TONNEL", 9, false, true], + ["EQC8FoZMlBcZhZ6Pr9sHGyHzkFv9y2B5X9tN61RvucLRzFZz", "JVault Token", "JVT", 9, false, true], + ["EQAAV0-SGQ9biuzgd5sgrnv0z_7s46bVvhQzBuWOLnSFCkhB", "Classic Wrapped NANO", "jNANO-C", 9, false, true], + ["EQBl3gg6AAdjgjO2ZoNU5Q5EzUIl8XMNZrix8Z5dJmkHUfxI", "Lavandos", "LAVE", 9, false, true], + ["EQDgSR_-4FDlXPfMVefXX1IIXMWBH4YfYV1a_8cJ0XsGVdsf", "PROTON JETTON", "PROTON", 9, false, true], + ["EQATcUc69sGSCCMSadsVUKdGwM1BMKS-HKCWGPk60xZGgwsK", "TON FISH MEMECOIN", "FISH", 9, false, true], + ["EQBtcL4JA-PdPiUkB8utHcqdaftmUSTqdL8Z1EeXePLti_nK", "Web3 TON Token", "WEB3", 3, false, true], + ["EQD5ty5IxV3HECEY1bbbdd7rNNY-ZcA-pAIGQXyyRZRED9v3", "Stable Metal", "STBL", 9, false, true], + ["EQBZ_cafPyDr5KUTs0aNxh0ZTDhkpEZONmLJA2SNGlLm4Cko", "Resistance Dog", "REDO", 9, false, true], + ["EQDv-yr41_CZ2urg2gfegVfa44PDPjIK9F-MilEDKDUIhlwZ", "ANON", "ANON", 9, false, true], + ["EQCtxbfa9bVXn7wsfK5V72fX1RFuc8kFDF7piMioWENgEBx5", "SquidTG", "SQD", 9, false, true], + ["EQC7rnHHtMVBKyhiGnAbtYIlzGxS0dfi3ZbHExFX0LYi9cAH", "@BTC25 MEMECOIN TonMiner", "@BTC25", 9, false, true], + ["EQAWpz2_G0NKxlG2VvgFbgZGPt8Y1qe0cGj-4Yw5BfmYR5iF", "Not Meme", "MEM", 9, false, true], + ["EQAl0ChJyynGWg0tGMCBM6soeKJrKtNm_IATf17jcFEQx0Ww", "povel durev", "durev", 9, false, true], + ["EQAX9J60va-0wIDMdqGLRMf7imJvG0Ytyi3Yxnq9y-nbNCq2", "Paper Plane", "PLANE", 9, false, true], + ["EQAVw-6sK7NJepSjgH1gW60lYEkHYzSmK9pHbXstCClDY4BV", "meh", "MEH", 9, false, true], + ["EQDRlQ8en7A2zsTuF7SdDOxMlZ_wFw0E7Eow3u9c4pSoe4Tg", "TGram", "TGRAM", 9, false, true], + ["EQCktEmAsvYBn8DPS6lu4QfatjEJJRLwD94aDqb8Ss6etuaA", "TIGER", "TIGER", 9, false, true], + ["EQDNJzbNKA8Ix2X7Tv1_jxdCqehPQgJaNbisoIkSq5srnfLs", "Burncoin", "BURN", 9, false, true], + ["EQDqz7LTwgj016kiTiSooM_ft8kveL2P4pj4fkJmsUV_an_X", "KAKAXA", "KAKAXA", 9, false, true], + ["EQBB-EMREJkIHVYG5DPiklOhWPcsCaxjL9HKmgRvuGtz_1lu", "Nobby Game", "SOX", 9, false, true], + ["EQAvlWFDxGF2lXm67y4yzC17wYKD9A0guwPkMs1gOsM__NOT", "Notcoin", "NOT", 9, false, true] +] \ No newline at end of file diff --git a/libs/ledgerjs/packages/cryptoassets/src/data/jetton.ts b/libs/ledgerjs/packages/cryptoassets/src/data/jetton.ts new file mode 100644 index 000000000000..9b7f9ece1eb9 --- /dev/null +++ b/libs/ledgerjs/packages/cryptoassets/src/data/jetton.ts @@ -0,0 +1,12 @@ +export type JettonToken = [ + string, // contractAddress + string, // name + string, // ticker + number, // magntude + boolean, // delisted + boolean, // enableCountervalues +]; + +import tokens from "./jetton.json"; + +export default tokens as JettonToken[]; diff --git a/libs/ledgerjs/packages/cryptoassets/src/tokens.ts b/libs/ledgerjs/packages/cryptoassets/src/tokens.ts index db83f2ed8a6b..734bdeb6dd02 100644 --- a/libs/ledgerjs/packages/cryptoassets/src/tokens.ts +++ b/libs/ledgerjs/packages/cryptoassets/src/tokens.ts @@ -6,6 +6,7 @@ import cardanoNativeTokens, { CardanoNativeToken } from "./data/cardanoNative"; import casperTokens, { CasperToken } from "./data/casper"; import erc20tokens, { ERC20Token } from "./data/erc20"; import esdttokens, { ElrondESDTToken } from "./data/esdt"; +import jettonTokens, { JettonToken } from "./data/jetton"; import polygonTokens, { PolygonERC20Token } from "./data/polygon-erc20"; import filecoinTokens, { FilecoinERC20Token } from "./data/filecoin-erc20"; import stellarTokens, { StellarToken } from "./data/stellar"; @@ -36,6 +37,7 @@ addTokens(cardanoNativeTokens.map(convertCardanoNativeTokens)); addTokens(stellarTokens.map(convertStellarTokens)); addTokens(casperTokens.map(convertCasperTokens)); addTokens(vechainTokens.map(convertVechainToken)); +addTokens(jettonTokens.map(convertJettonToken)); type TokensListOptions = { withDelisted: boolean; @@ -521,3 +523,37 @@ function convertCasperTokens([ ], }; } + +export function convertJettonToken([ + address, + name, + ticker, + magnitude, + delisted, + enableCountervalues, +]: JettonToken): TokenCurrency | undefined { + const parentCurrency = findCryptoCurrencyById("ton"); + + if (!parentCurrency) { + return; + } + + return { + type: "TokenCurrency", + id: "ton/jetton/" + address.toLocaleLowerCase(), + contractAddress: address, + parentCurrency, + tokenType: "jetton", + name, + ticker, + delisted, + disableCountervalue: !enableCountervalues, + units: [ + { + name, + code: ticker, + magnitude, + }, + ], + }; +} diff --git a/libs/ui/packages/crypto-icons/src/svg/BURN.svg b/libs/ui/packages/crypto-icons/src/svg/BURN.svg new file mode 100644 index 000000000000..99ec3c87d09e --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/BURN.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/FNZ.svg b/libs/ui/packages/crypto-icons/src/svg/FNZ.svg new file mode 100644 index 000000000000..373e9e071fae --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/FNZ.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/GEMSTON.svg b/libs/ui/packages/crypto-icons/src/svg/GEMSTON.svg new file mode 100644 index 000000000000..85b495cb6507 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/GEMSTON.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/GLINT.svg b/libs/ui/packages/crypto-icons/src/svg/GLINT.svg new file mode 100644 index 000000000000..c683cb6dba62 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/GLINT.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/GRAM.svg b/libs/ui/packages/crypto-icons/src/svg/GRAM.svg new file mode 100644 index 000000000000..d3232727427e --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/GRAM.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/HTON1.svg b/libs/ui/packages/crypto-icons/src/svg/HTON1.svg new file mode 100644 index 000000000000..ab95e64a26de --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/HTON1.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/JETTON.svg b/libs/ui/packages/crypto-icons/src/svg/JETTON.svg new file mode 100644 index 000000000000..df646e11c986 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/JETTON.svg @@ -0,0 +1,4 @@ + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/JMNT.svg b/libs/ui/packages/crypto-icons/src/svg/JMNT.svg new file mode 100644 index 000000000000..5c47ac845702 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/JMNT.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/JVT.svg b/libs/ui/packages/crypto-icons/src/svg/JVT.svg new file mode 100644 index 000000000000..406863681b59 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/JVT.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/LAVE.svg b/libs/ui/packages/crypto-icons/src/svg/LAVE.svg new file mode 100644 index 000000000000..2acf67b489cd --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/LAVE.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/MEH.svg b/libs/ui/packages/crypto-icons/src/svg/MEH.svg new file mode 100644 index 000000000000..63895a3c1820 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/MEH.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/MEM.svg b/libs/ui/packages/crypto-icons/src/svg/MEM.svg new file mode 100644 index 000000000000..78bf10dd7944 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/MEM.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/NOT.svg b/libs/ui/packages/crypto-icons/src/svg/NOT.svg new file mode 100644 index 000000000000..78bf10dd7944 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/NOT.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/PLANE.svg b/libs/ui/packages/crypto-icons/src/svg/PLANE.svg new file mode 100644 index 000000000000..c47436a2c89e --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/PLANE.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/PROTON.svg b/libs/ui/packages/crypto-icons/src/svg/PROTON.svg new file mode 100644 index 000000000000..22f352490301 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/PROTON.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/PUNK.svg b/libs/ui/packages/crypto-icons/src/svg/PUNK.svg new file mode 100644 index 000000000000..0901a3139e84 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/PUNK.svg @@ -0,0 +1,4 @@ + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/RAFF.svg b/libs/ui/packages/crypto-icons/src/svg/RAFF.svg new file mode 100644 index 000000000000..a0e712034c0d --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/RAFF.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/SCALE.svg b/libs/ui/packages/crypto-icons/src/svg/SCALE.svg new file mode 100644 index 000000000000..86a0682d991f --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/SCALE.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/SOX.svg b/libs/ui/packages/crypto-icons/src/svg/SOX.svg new file mode 100644 index 000000000000..30985e8d94e7 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/SOX.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/STON.svg b/libs/ui/packages/crypto-icons/src/svg/STON.svg new file mode 100644 index 000000000000..404f619a0bc2 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/STON.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/TONNEL.svg b/libs/ui/packages/crypto-icons/src/svg/TONNEL.svg new file mode 100644 index 000000000000..42bd98ea6c8f --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/TONNEL.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/UP.svg b/libs/ui/packages/crypto-icons/src/svg/UP.svg new file mode 100644 index 000000000000..e74c71a27d66 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/UP.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/WEB3.svg b/libs/ui/packages/crypto-icons/src/svg/WEB3.svg new file mode 100644 index 000000000000..6c40a407d246 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/WEB3.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/jUSDC.svg b/libs/ui/packages/crypto-icons/src/svg/jUSDC.svg new file mode 100644 index 000000000000..f784fe28bc0d --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/jUSDC.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/jUSDT.svg b/libs/ui/packages/crypto-icons/src/svg/jUSDT.svg new file mode 100644 index 000000000000..22bb80b8e526 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/jUSDT.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/jWBTC.svg b/libs/ui/packages/crypto-icons/src/svg/jWBTC.svg new file mode 100644 index 000000000000..f429fee4b030 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/jWBTC.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/stTON.svg b/libs/ui/packages/crypto-icons/src/svg/stTON.svg new file mode 100644 index 000000000000..93a4053a3620 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/stTON.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/libs/ui/packages/crypto-icons/src/svg/tGRAM.svg b/libs/ui/packages/crypto-icons/src/svg/tGRAM.svg new file mode 100644 index 000000000000..78bf10dd7944 --- /dev/null +++ b/libs/ui/packages/crypto-icons/src/svg/tGRAM.svg @@ -0,0 +1,3 @@ + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6408178ceb7e..0f9bfb593683 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2676,6 +2676,9 @@ importers: expect: specifier: ^27.4.6 version: 27.5.1 + imurmurhash: + specifier: ^0.1.4 + version: 0.1.4 invariant: specifier: ^2.2.2 version: 2.2.4 @@ -2689,6 +2692,9 @@ importers: specifier: ^7.8.1 version: 7.8.1 devDependencies: + '@types/imurmurhash': + specifier: ^0.1.4 + version: 0.1.4 '@types/invariant': specifier: ^2.2.2 version: 2.2.37