diff --git a/packages/connect-explorer/src/pages/methods/solana/solanaComposeTransaction.mdx b/packages/connect-explorer/src/pages/methods/solana/solanaComposeTransaction.mdx new file mode 100644 index 00000000000..b1c63bbfe92 --- /dev/null +++ b/packages/connect-explorer/src/pages/methods/solana/solanaComposeTransaction.mdx @@ -0,0 +1,96 @@ +import { SolanaComposeTransaction } from '@trezor/connect/src/types/api/solana'; + +import { ParamsTable } from '../../../components/ParamsTable'; +import { CommonParamsLink } from '../../../components/CommonParamsLink'; +import { ApiPlayground } from '../../../components/ApiPlayground'; + + + +export const paramDescriptions = { + path: 'minimum length is `2`. [read more](/details/path)', + fromAddress: 'Sender address', + toAddress: + 'Recipient address. In case of token transfer, this still means the owner address of the token account. The associated token account is created automatically.', + amount: 'Amount to send in decimal string format', + priorityFees: + 'Fee configuration. If not set, it defaults to hardcoded values. It is recommended to simulate the transaction using `blockchainEstimateFee`', + token: 'Token details in case of token transfer', + blockHash: 'Recent Block hash', + lastValidBlockHeight: 'Recent Block height', + coin: '"SOL" for mainnet (default), "DSOL" for devnet', + identity: "Blockchain connection identity. It's used to separate multiple connections.", +}; + +## Solana: Compose transaction + +Compose a Solana transfer transaction that can be later signed on device using [solanaSignTransaction](/methods/solana/solanaSignTransaction/). + +The transaction may be a native SOL transfer or a token transfer. + +```javascript +const result = await TrezorConnect.solanaComposeTransaction(params); +``` + +### Params + + + +#### SolanaComposeTransaction + + + +### Examples + +```javascript +TrezorConnect.solanaComposeTransaction({ + fromAddress: '...', + toAddress: '...', + amount: '0.1', + blockHash: '...', + lastValidBlockHeight: 123456, + coin: 'SOL', +}); +``` + +### Result + +[SolanaComposedTransaction type](https://github.com/trezor/trezor-suite/blob/develop/packages/connect/src/types/api/solana/index.ts) + +```javascript +{ + success: true, + payload: { + serializedTx: string, + additionalInfo: { + isCreatingAccount: boolean, + // in case of token transfer: + newTokenAccountProgramName: "spl-token" | "spl-token-2022", + tokenAccountInfo: { + baseAddress: string, + tokenProgram: string, + tokenMint: string, + tokenAccount: string, + }, + } + } +} +``` + +Error + +```javascript +{ + success: false, + payload: { + error: string // error message + } +} +``` diff --git a/packages/connect-explorer/src/pages/methods/solana/solanaSignTransaction.mdx b/packages/connect-explorer/src/pages/methods/solana/solanaSignTransaction.mdx index 084f840aca5..e03ff9586f2 100644 --- a/packages/connect-explorer/src/pages/methods/solana/solanaSignTransaction.mdx +++ b/packages/connect-explorer/src/pages/methods/solana/solanaSignTransaction.mdx @@ -19,6 +19,8 @@ import signTransaction from '../../../data/methods/solana/signTransaction.ts'; export const paramDescriptions = { path: 'minimum length is `2`. [read more](/details/path)', serializedTx: '', + serialize: + 'If `true`, the transaction will be deserialized before signing and serialized back after signing. Without this option, the method will only return the signature by itself.', }; ## Solana: Sign transaction diff --git a/packages/connect/e2e/__fixtures__/index.ts b/packages/connect/e2e/__fixtures__/index.ts index f9df6ba282a..66a8af65409 100644 --- a/packages/connect/e2e/__fixtures__/index.ts +++ b/packages/connect/e2e/__fixtures__/index.ts @@ -56,6 +56,7 @@ export { default as signTransactionReplace } from './signTransactionReplace'; export { default as signTransactionSegwit } from './signTransactionSegwit'; export { default as signTransactionTaproot } from './signTransactionTaproot'; export { default as signTransactionZcash } from './signTransactionZcash'; +export { default as solanaComposeTransaction } from './solanaComposeTransaction'; export { default as solanaGetAddress } from './solanaGetAddress'; export { default as solanaGetPublicKey } from './solanaGetPublicKey'; export { default as solanaSignTransaction } from './solanaSignTransaction'; diff --git a/packages/connect/e2e/__fixtures__/solanaComposeTransaction.ts b/packages/connect/e2e/__fixtures__/solanaComposeTransaction.ts new file mode 100644 index 00000000000..d570791679a --- /dev/null +++ b/packages/connect/e2e/__fixtures__/solanaComposeTransaction.ts @@ -0,0 +1,97 @@ +export default { + method: 'solanaComposeTransaction', + setup: { + mnemonic: undefined, // device is not used in this test case + }, + tests: [ + { + description: 'Basic SOL transfer', + params: { + fromAddress: 'ANctUhC7YZPueiv4T8bkDcHYEAJ7Hwoxhvgnr2QkF8uR', + toAddress: '5Q9c3XoBef8BYA5RzSmogWnRrQas6HPwYuo4AYPafpom', + amount: '0.01', + blockHash: 'BXim2ZLR2UZ4JQxhNGaGngbRaySWej4x8zqaw7TJ4GLo', + lastValidBlockHeight: 290999279, + coin: 'sol', + }, + result: { + serializedTx: + '0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010002048b42f51ed008e27e643818f0503bdfeb6535dc815e3a9fd41a7ce42344f888dc415cd5ca15bbbbcf07713fe3f690ebaab81c9cfa8f8a95f91b17deabb953b83400000000000000000000000000000000000000000000000000000000000000000306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a400000009c7382bc11ef07feb1a080bc78d2f7f6906ae7cb7c389057fd99ae3e97c811e00303000502400d030003000903a086010000000000020200010c020000008096980000000000', + additionalInfo: { + isCreatingAccount: false, + }, + }, + }, + { + description: 'SOL token transfer', + params: { + fromAddress: 'ANctUhC7YZPueiv4T8bkDcHYEAJ7Hwoxhvgnr2QkF8uR', + toAddress: '5Q9c3XoBef8BYA5RzSmogWnRrQas6HPwYuo4AYPafpom', + amount: '1', + token: { + mint: 'HBoNJ5v8g71s2boRivrHnfSB5MVPLDHHyVjruPfhGkvL', + program: 'spl-token', + decimals: 1, + accounts: [ + { + publicKey: '6EjZ73R3oEUHQL4zczkx3pRD3acysP3ug7hwMAdzdtNQ', + balance: '30', + }, + ], + }, + blockHash: 'HaVyjCbyqQa2sbwXTLKskNretcsRVws6VEBYP6xZMKm', + lastValidBlockHeight: 290999384, + coin: 'sol', + }, + result: { + serializedTx: + '0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010003068b42f51ed008e27e643818f0503bdfeb6535dc815e3a9fd41a7ce42344f888dc4dcf19bed853ae158e8c5ad250530e09f8fb4b82bee35ec1a3c89d30b2b183f559e1abe20307a4f7ed6e586aea367e24b7d9f5c6d4d0969a72d9d0dd3436abe10306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a40000000f07f39f63a83ade085f851ca297f39b6993fd7b0fccba5e3aff7511fad40da3906ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9043f2bcc70fd3420cfbdcdd1da062d993dd46072ab2da56948d6ba1006138da00303000502400d030003000903a0860100000000000504010402000a0c0a0000000000000001', + additionalInfo: { + isCreatingAccount: false, + tokenAccountInfo: { + baseAddress: '5Q9c3XoBef8BYA5RzSmogWnRrQas6HPwYuo4AYPafpom', + tokenProgram: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + tokenMint: 'HBoNJ5v8g71s2boRivrHnfSB5MVPLDHHyVjruPfhGkvL', + tokenAccount: '73rsTqUoMd34Y3YwXtu4An2LkncLF9SeDY6TGUFfksfe', + }, + }, + }, + }, + { + description: 'SOL token transfer - new account', + params: { + fromAddress: 'ANctUhC7YZPueiv4T8bkDcHYEAJ7Hwoxhvgnr2QkF8uR', + toAddress: 'Aey9o8JXzTcQdjJVrV4Y56xzt5qHkLPWLgAQmaodUojm', + amount: '1', + token: { + mint: 'HBoNJ5v8g71s2boRivrHnfSB5MVPLDHHyVjruPfhGkvL', + program: 'spl-token', + decimals: 1, + accounts: [ + { + publicKey: '6EjZ73R3oEUHQL4zczkx3pRD3acysP3ug7hwMAdzdtNQ', + balance: '30', + }, + ], + }, + blockHash: 'FuVcUvTCAEAefjSb7twrdnM98KFuxPhe9idGNZ3jb4zT', + lastValidBlockHeight: 290999698, + coin: 'sol', + }, + result: { + serializedTx: + '0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010006098b42f51ed008e27e643818f0503bdfeb6535dc815e3a9fd41a7ce42344f888dc297187b6006964cafcb0a71cdf7d16cf80563debd388a20ed2beae6de8f0e23d4dcf19bed853ae158e8c5ad250530e09f8fb4b82bee35ec1a3c89d30b2b183f500000000000000000000000000000000000000000000000000000000000000008f7329c364a8e1d0376058cc672d81ce5ff8585a46c0adcb918ca7e3f0dc82a08c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f8590306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a40000000f07f39f63a83ade085f851ca297f39b6993fd7b0fccba5e3aff7511fad40da3906ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9dd762ba278d68a101267dbdcd6b7e6e1a84e080acdf553ba8caf0d072fbb6d200406000502400d030006000903a0860100000000000506000104070308000804020701000a0c0a0000000000000001', + additionalInfo: { + isCreatingAccount: true, + newTokenAccountProgramName: 'spl-token', + tokenAccountInfo: { + baseAddress: 'Aey9o8JXzTcQdjJVrV4Y56xzt5qHkLPWLgAQmaodUojm', + tokenProgram: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + tokenMint: 'HBoNJ5v8g71s2boRivrHnfSB5MVPLDHHyVjruPfhGkvL', + tokenAccount: '3nn86A71hFhoqYgPqLWSXdoxUwtfJNWoevBQUouAjSEg', + }, + }, + }, + }, + ], +}; diff --git a/packages/connect/e2e/__wscache__/index.js b/packages/connect/e2e/__wscache__/index.js index cad0fd21f42..e31228f6cd1 100644 --- a/packages/connect/e2e/__wscache__/index.js +++ b/packages/connect/e2e/__wscache__/index.js @@ -6,6 +6,9 @@ const transformCoinsJson = json => { Object.keys(json).forEach(key => { json[key].forEach(coin => { if (coin.blockchain_link) { + // Skip for Solana, it uses a combination of HTTP and WebSocket, therefore it is not supported currently + if (coin.blockchain_link.type === 'solana') return; + const query = `?type=${coin.blockchain_link.type}&shortcut=${coin.shortcut}&suffix=/websocket`; coin.blockchain_link.url = [`ws://localhost:18088/${query}`]; } diff --git a/packages/connect/package.json b/packages/connect/package.json index 2dc0beb449e..19d6b5d58e4 100644 --- a/packages/connect/package.json +++ b/packages/connect/package.json @@ -72,10 +72,17 @@ "@ethereumjs/common": "^4.4.0", "@ethereumjs/tx": "^5.4.0", "@fivebinaries/coin-selection": "3.0.0", + "@mobily/ts-belt": "^3.13.1", "@noble/hashes": "^1.6.1", "@scure/bip39": "^1.5.1", + "@solana-program/compute-budget": "^0.6.1", + "@solana-program/system": "^0.6.2", + "@solana-program/token": "^0.4.1", + "@solana-program/token-2022": "^0.3.1", + "@solana/web3.js": "^2.0.0", "@trezor/blockchain-link": "workspace:*", "@trezor/blockchain-link-types": "workspace:*", + "@trezor/blockchain-link-utils": "workspace:*", "@trezor/connect-analytics": "workspace:*", "@trezor/connect-common": "workspace:*", "@trezor/crypto-utils": "workspace:*", diff --git a/suite-common/wallet-utils/src/__fixtures__/solanaUtils.ts b/packages/connect/src/api/solana/__fixtures__/solanaUtils.ts similarity index 100% rename from suite-common/wallet-utils/src/__fixtures__/solanaUtils.ts rename to packages/connect/src/api/solana/__fixtures__/solanaUtils.ts diff --git a/suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts b/packages/connect/src/api/solana/__tests__/solanaUtils.test.ts similarity index 94% rename from suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts rename to packages/connect/src/api/solana/__tests__/solanaUtils.test.ts index cb41ad7bb7e..5e45c8c6507 100644 --- a/suite-common/wallet-utils/src/__tests__/solanaUtils.test.ts +++ b/packages/connect/src/api/solana/__tests__/solanaUtils.test.ts @@ -4,6 +4,7 @@ import { buildTokenTransferInstruction, buildTokenTransferTransaction, getMinimumRequiredTokenAccountsForTransfer, + getLamportsFromSol, } from '../solanaUtils'; describe('solana utils', () => { @@ -83,4 +84,9 @@ describe('solana utils', () => { }); }); }); + + it('getLamportsFromSol', () => { + expect(getLamportsFromSol('1')).toEqual(1000000000n); + expect(getLamportsFromSol('0.000000001')).toEqual(1n); + }); }); diff --git a/packages/connect/src/api/solana/api/index.ts b/packages/connect/src/api/solana/api/index.ts index 163de912550..281e228608d 100644 --- a/packages/connect/src/api/solana/api/index.ts +++ b/packages/connect/src/api/solana/api/index.ts @@ -1,3 +1,4 @@ +export { default as solanaComposeTransaction } from './solanaComposeTransaction'; export { default as solanaGetAddress } from './solanaGetAddress'; export { default as solanaGetPublicKey } from './solanaGetPublicKey'; export { default as solanaSignTransaction } from './solanaSignTransaction'; diff --git a/packages/connect/src/api/solana/api/solanaComposeTransaction.ts b/packages/connect/src/api/solana/api/solanaComposeTransaction.ts new file mode 100644 index 00000000000..831c7878b73 --- /dev/null +++ b/packages/connect/src/api/solana/api/solanaComposeTransaction.ts @@ -0,0 +1,117 @@ +import { Assert } from '@trezor/schema-utils'; +import { SYSTEM_PROGRAM_PUBLIC_KEY } from '@trezor/blockchain-link-utils/src/solana'; + +import { AbstractMethod } from '../../../core/AbstractMethod'; +import { SolanaComposeTransaction as SolanaComposeTransactionSchema } from '../../../types/api/solana'; +import { ERRORS } from '../../../constants'; +import { CoinInfo } from '../../../types'; +import { initBlockchain, isBackendSupported } from '../../../backend/BlockchainLink'; +import { getCoinInfo } from '../../../data/coinInfo'; +import { + buildTokenTransferTransaction, + buildTransferTransaction, + dummyPriorityFeesForFeeEstimation, + fetchAccountOwnerAndTokenInfoForAddress, +} from '../solanaUtils'; + +type SolanaComposeTransactionParams = SolanaComposeTransactionSchema & { + coinInfo: CoinInfo; +}; + +export default class SolanaComposeTransaction extends AbstractMethod< + 'solanaComposeTransaction', + SolanaComposeTransactionParams +> { + init() { + this.useDevice = false; + this.useUi = false; + + const { payload } = this; + + // validate bundle type + Assert(SolanaComposeTransactionSchema, payload); + + const coinInfo = getCoinInfo(payload.coin || 'sol'); + if (!coinInfo) { + throw ERRORS.TypedError('Method_UnknownCoin'); + } + // validate backend + isBackendSupported(coinInfo); + + this.params = { + coinInfo, + ...payload, + }; + } + + get info() { + return 'Compose Solana transaction'; + } + + async run() { + const backend = await initBlockchain( + this.params.coinInfo, + this.postMessage, + this.params.identity, + ); + + const [recipientAccountOwner, recipientTokenAccounts] = this.params.token + ? await fetchAccountOwnerAndTokenInfoForAddress( + backend, + this.params.toAddress, + this.params.token.mint, + this.params.token.program, + ) + : [undefined, undefined]; + + const tokenTransferTxAndDestinationAddress = + this.params.token && this.params.token.accounts + ? await buildTokenTransferTransaction( + this.params.fromAddress, + this.params.toAddress, + recipientAccountOwner || SYSTEM_PROGRAM_PUBLIC_KEY, // toAddressOwner + this.params.token.mint, + this.params.amount || '0', + this.params.token.decimals, + this.params.token.accounts, + recipientTokenAccounts, + this.params.blockHash, + this.params.lastValidBlockHeight, + this.params.priorityFees || dummyPriorityFeesForFeeEstimation, + this.params.token.program, + ) + : undefined; + + if (this.params.token && !tokenTransferTxAndDestinationAddress) + throw ERRORS.TypedError('Method_InvalidParameter', 'Token accounts not found'); + + const tx = tokenTransferTxAndDestinationAddress + ? tokenTransferTxAndDestinationAddress.transaction + : await buildTransferTransaction( + this.params.fromAddress, + this.params.toAddress, + this.params.amount, + this.params.blockHash, + this.params.lastValidBlockHeight, + this.params.priorityFees || dummyPriorityFeesForFeeEstimation, + ); + + const isCreatingAccount = + this.params.token && + recipientTokenAccounts === undefined && + // if the recipient account has no owner, it means it's a new account and needs the token account to be created + (recipientAccountOwner === SYSTEM_PROGRAM_PUBLIC_KEY || recipientAccountOwner == null); + const newTokenAccountProgramName = isCreatingAccount + ? this.params.token?.program + : undefined; + + return { + serializedTx: tx.serialize(), + additionalInfo: { + isCreatingAccount: !!isCreatingAccount, + newTokenAccountProgramName, + tokenAccountInfo: tokenTransferTxAndDestinationAddress?.tokenAccountInfo, + }, + }; + } +} diff --git a/packages/connect/src/api/solana/api/solanaSignTransaction.ts b/packages/connect/src/api/solana/api/solanaSignTransaction.ts index 26a64d3fb5f..4bf818f0944 100644 --- a/packages/connect/src/api/solana/api/solanaSignTransaction.ts +++ b/packages/connect/src/api/solana/api/solanaSignTransaction.ts @@ -6,12 +6,12 @@ import { getFirmwareRange } from '../../common/paramsValidator'; import { getMiscNetwork } from '../../../data/coinInfo'; import { validatePath } from '../../../utils/pathUtils'; import { transformAdditionalInfo } from '../additionalInfo'; +import { createTransactionShimFromHex } from '../solanaUtils'; import { SolanaSignTransaction as SolanaSignTransactionSchema } from '../../../types/api/solana'; -export default class SolanaSignTransaction extends AbstractMethod< - 'solanaSignTransaction', - PROTO.SolanaSignTx -> { +type Params = PROTO.SolanaSignTx & { serialize: boolean }; + +export default class SolanaSignTransaction extends AbstractMethod<'solanaSignTransaction', Params> { init() { this.requiredPermissions = ['read', 'write']; this.requiredDeviceCapabilities = ['Capability_Solana']; @@ -33,6 +33,7 @@ export default class SolanaSignTransaction extends AbstractMethod< address_n: path, serialized_tx: payload.serializedTx, additional_info: transformAdditionalInfo(payload.additionalInfo), + serialize: !!payload.serialize, }; } @@ -42,6 +43,28 @@ export default class SolanaSignTransaction extends AbstractMethod< async run() { const cmd = this.device.getCommands(); + + if (this.params.serialize) { + const tx = await createTransactionShimFromHex(this.params.serialized_tx); + + const { message } = await cmd.typedCall('SolanaSignTx', 'SolanaTxSignature', { + ...this.params, + serialized_tx: tx.serializeMessage(), + }); + + const addressCall = await cmd.typedCall('SolanaGetAddress', 'SolanaAddress', { + address_n: this.params.address_n, + show_display: false, + chunkify: false, + }); + const { address } = addressCall.message; + + tx.addSignature(address, message.signature); + const signedSerializedTx = tx.serialize(); + + return { signature: message.signature, serializedTx: signedSerializedTx }; + } + const { message } = await cmd.typedCall('SolanaSignTx', 'SolanaTxSignature', this.params); return { signature: message.signature }; diff --git a/suite-common/wallet-utils/src/solanaUtils.ts b/packages/connect/src/api/solana/solanaUtils.ts similarity index 81% rename from suite-common/wallet-utils/src/solanaUtils.ts rename to packages/connect/src/api/solana/solanaUtils.ts index 84c4eef6558..6edc746ec20 100644 --- a/suite-common/wallet-utils/src/solanaUtils.ts +++ b/packages/connect/src/api/solana/solanaUtils.ts @@ -4,6 +4,7 @@ import { type Blockhash, type CompilableTransactionMessage, type TransactionMessage, + type Transaction, } from '@solana/web3.js'; import { BigNumber } from '@trezor/utils/src/bigNumber'; @@ -11,26 +12,37 @@ import type { TokenAccount } from '@trezor/blockchain-link-types'; import { solanaUtils as SolanaBlockchainLinkUtils } from '@trezor/blockchain-link-utils'; import type { TokenProgramName } from '@trezor/blockchain-link-utils/src/solana'; -import { getLamportsFromSol } from './sendFormUtils'; +import { Blockchain } from '../../backend/Blockchain'; const { SYSTEM_PROGRAM_PUBLIC_KEY, tokenProgramsInfo } = SolanaBlockchainLinkUtils; -const loadSolanaLib = async () => await import('@solana/web3.js'); +const loadSolanaLib = async () => + await import(/* webpackChunkName: "vendor-solana-web3js" */ '@solana/web3.js'); const loadSolanaComputeBudgetProgramLib = async () => - await import('@solana-program/compute-budget'); -const loadSolanaSystemProgramLib = async () => await import('@solana-program/system'); + await import( + /* webpackChunkName: "vendor-solana-program-compute-budget" */ '@solana-program/compute-budget' + ); +const loadSolanaSystemProgramLib = async () => + await import(/* webpackChunkName: "vendor-solana-program-system" */ '@solana-program/system'); const loadSolanaTokenProgramLib = async (tokenProgramName: TokenProgramName) => { switch (tokenProgramName) { case 'spl-token': - return await import('@solana-program/token'); + return await import( + /* webpackChunkName: "vendor-solana-program-token" */ '@solana-program/token' + ); case 'spl-token-2022': - return await import('@solana-program/token-2022'); + return await import( + /* webpackChunkName: "vendor-solana-program-token-2022" */ '@solana-program/token-2022' + ); default: throw new Error(`Unsupported token program: ${tokenProgramName}`); } }; +export const getLamportsFromSol = (amountInSol: string) => + BigInt(new BigNumber(amountInSol).times(10 ** 9).toString()); + type PriorityFees = { computeUnitPrice: string; computeUnitLimit: string }; export const dummyPriorityFeesForFeeEstimation: PriorityFees = { @@ -38,10 +50,8 @@ export const dummyPriorityFeesForFeeEstimation: PriorityFees = { computeUnitLimit: '200000', }; -async function createTransactionShim(message: CompilableTransactionMessage) { - const { compileTransaction, getBase16Codec, getTransactionEncoder } = await loadSolanaLib(); - - let transaction = compileTransaction(message); +async function createTransactionShimCommon(transaction: Transaction) { + const { getBase16Codec, getTransactionEncoder } = await loadSolanaLib(); return { addSignature(signerPubKey: string, signatureHex: string) { @@ -66,6 +76,25 @@ async function createTransactionShim(message: CompilableTransactionMessage) { }; } +export async function createTransactionShim(message: CompilableTransactionMessage) { + const { compileTransaction } = await loadSolanaLib(); + + const transaction = compileTransaction(message); + + return createTransactionShimCommon(transaction); +} + +export async function createTransactionShimFromHex(rawTx: string) { + const { getBase16Encoder, getCompiledTransactionMessageDecoder, decompileTransactionMessage } = + await loadSolanaLib(); + + const txByteArray = getBase16Encoder().encode(rawTx); + const compiledMessage = getCompiledTransactionMessageDecoder().decode(txByteArray); + const message = decompileTransactionMessage(compiledMessage); + + return createTransactionShim(message); +} + const addPriorityFees = async ( message: TMessage, priorityFees: PriorityFees, @@ -374,3 +403,35 @@ export const buildTokenTransferTransaction = async ( : undefined, }; }; + +export const fetchAccountOwnerAndTokenInfoForAddress = async ( + blockchain: Blockchain, + address: string, + mint: string, + tokenProgram: TokenProgramName, +) => { + // Fetch data about recipient account owner if this is a token transfer + // We need this in order to validate the address and ensure transfers go through + let accountOwner: string | undefined; + let tokenInfo: TokenAccount | undefined; + + const accountInfoResponse = await blockchain.getAccountInfo({ + descriptor: address, + details: 'tokens', + }); + + if (accountInfoResponse) { + const associatedTokenAccount = await getAssociatedTokenAccountAddress( + address, + mint, + tokenProgram, + ); + + accountOwner = accountInfoResponse?.misc?.owner; + tokenInfo = accountInfoResponse?.tokens + ?.find(token => token.contract === mint) + ?.accounts?.find(account => associatedTokenAccount.toString() === account.publicKey); + } + + return [accountOwner, tokenInfo] as const; +}; diff --git a/packages/connect/src/factory.ts b/packages/connect/src/factory.ts index 531c4d6ede6..1f2d837f125 100644 --- a/packages/connect/src/factory.ts +++ b/packages/connect/src/factory.ts @@ -175,6 +175,8 @@ export const factory = < signTransaction: params => call({ ...params, method: 'signTransaction' }), + solanaComposeTransaction: params => call({ ...params, method: 'solanaComposeTransaction' }), + solanaGetPublicKey: params => call({ ...params, method: 'solanaGetPublicKey' }), solanaGetAddress: params => call({ ...params, method: 'solanaGetAddress' }), diff --git a/packages/connect/src/types/api/index.ts b/packages/connect/src/types/api/index.ts index 9e9ab63f5db..a9c417b11f7 100644 --- a/packages/connect/src/types/api/index.ts +++ b/packages/connect/src/types/api/index.ts @@ -73,6 +73,7 @@ import { setTransports } from './setTransports'; import { showDeviceTutorial } from './showDeviceTutorial'; import { signMessage } from './signMessage'; import { signTransaction } from './signTransaction'; +import { solanaComposeTransaction } from './solanaComposeTransaction'; import { solanaGetAddress } from './solanaGetAddress'; import { solanaGetPublicKey } from './solanaGetPublicKey'; import { solanaSignTransaction } from './solanaSignTransaction'; @@ -312,6 +313,9 @@ export interface TrezorConnect { // https://connect.trezor.io/9/methods/bitcoin/signTransaction/ signTransaction: typeof signTransaction; + // https://connect.trezor.io/9/methods/solana/solanaComposeTransaction/ + solanaComposeTransaction: typeof solanaComposeTransaction; + // https://connect.trezor.io/9/methods/solana/solanaGetPublicKey/ solanaGetPublicKey: typeof solanaGetPublicKey; diff --git a/packages/connect/src/types/api/solana/index.ts b/packages/connect/src/types/api/solana/index.ts index f1498e78359..9b6decfc51e 100644 --- a/packages/connect/src/types/api/solana/index.ts +++ b/packages/connect/src/types/api/solana/index.ts @@ -32,9 +32,49 @@ export const SolanaSignTransaction = Type.Object({ path: Type.Union([Type.String(), Type.Array(Type.Number())]), serializedTx: Type.String(), additionalInfo: Type.Optional(SolanaTxAdditionalInfo), + serialize: Type.Optional(Type.Boolean()), }); export type SolanaSignedTransaction = Static; export const SolanaSignedTransaction = Type.Object({ signature: Type.String(), + serializedTx: Type.Optional(Type.String()), +}); + +export type SolanaProgramName = Static; +export const SolanaProgramName = Type.Union([ + Type.Literal('spl-token'), + Type.Literal('spl-token-2022'), +]); + +export type SolanaComposeTransaction = Static; +export const SolanaComposeTransaction = Type.Object({ + fromAddress: Type.String(), + toAddress: Type.String(), + amount: Type.String(), + blockHash: Type.String(), + lastValidBlockHeight: Type.Number(), + priorityFees: Type.Optional( + Type.Object({ computeUnitPrice: Type.String(), computeUnitLimit: Type.String() }), + ), + token: Type.Optional( + Type.Object({ + mint: Type.String(), + program: SolanaProgramName, + decimals: Type.Number(), + accounts: Type.Array(Type.Object({ publicKey: Type.String(), balance: Type.String() })), + }), + ), + coin: Type.Optional(Type.String()), + identity: Type.Optional(Type.String()), +}); + +export type SolanaComposedTransaction = Static; +export const SolanaComposedTransaction = Type.Object({ + serializedTx: Type.String(), + additionalInfo: Type.Object({ + isCreatingAccount: Type.Boolean(), + newTokenAccountProgramName: Type.Optional(SolanaProgramName), + tokenAccountInfo: Type.Optional(SolanaTxTokenAccountInfo), + }), }); diff --git a/packages/connect/src/types/api/solanaComposeTransaction.ts b/packages/connect/src/types/api/solanaComposeTransaction.ts new file mode 100644 index 00000000000..c0926eb7aa3 --- /dev/null +++ b/packages/connect/src/types/api/solanaComposeTransaction.ts @@ -0,0 +1,6 @@ +import type { Params, Response } from '../params'; +import type { SolanaComposeTransaction, SolanaComposedTransaction } from './solana'; + +export declare function solanaComposeTransaction( + params: Params, +): Response; diff --git a/packages/connect/tsconfig.json b/packages/connect/tsconfig.json index 08c3b1417df..59a02025eda 100644 --- a/packages/connect/tsconfig.json +++ b/packages/connect/tsconfig.json @@ -4,6 +4,7 @@ "references": [ { "path": "../blockchain-link" }, { "path": "../blockchain-link-types" }, + { "path": "../blockchain-link-utils" }, { "path": "../connect-analytics" }, { "path": "../connect-common" }, { "path": "../crypto-utils" }, diff --git a/packages/connect/tsconfig.lib.json b/packages/connect/tsconfig.lib.json index 09960e56f47..9f31e0592a1 100644 --- a/packages/connect/tsconfig.lib.json +++ b/packages/connect/tsconfig.lib.json @@ -13,6 +13,9 @@ { "path": "../blockchain-link-types" }, + { + "path": "../blockchain-link-utils" + }, { "path": "../connect-analytics" }, diff --git a/packages/suite/src/hooks/wallet/__fixtures__/useSendForm.ts b/packages/suite/src/hooks/wallet/__fixtures__/useSendForm.ts index cef7e009ce7..d4bd9f0b76a 100644 --- a/packages/suite/src/hooks/wallet/__fixtures__/useSendForm.ts +++ b/packages/suite/src/hooks/wallet/__fixtures__/useSendForm.ts @@ -1219,7 +1219,21 @@ export const setMax = [ }, selectedAccount: SOL_ACCOUNT, }, + connect: [ + undefined, + { + success: true, + payload: { + serializedTx: 'serializedABCD', + additionalInfo: { + isCreatingAccount: false, + newTokenAccountProgramName: undefined, + }, + }, + }, + ], finalResult: { + solanaComposeTransactionCalls: 1, estimateFeeCalls: 1, composedLevels: { normal: { diff --git a/packages/suite/src/hooks/wallet/__tests__/useSendForm.test.tsx b/packages/suite/src/hooks/wallet/__tests__/useSendForm.test.tsx index c6f4893962f..57072beedd0 100644 --- a/packages/suite/src/hooks/wallet/__tests__/useSendForm.test.tsx +++ b/packages/suite/src/hooks/wallet/__tests__/useSendForm.test.tsx @@ -103,6 +103,7 @@ const Component = ({ callback }: { callback: TestCallback }) => { interface Result { composeTransactionCalls?: number; composeTransactionParams?: any; // partial @trezor/connect params + solanaComposeTransactionCalls?: number; estimateFeeCalls?: number; // used in ETH estimateFeeParams?: any; // partial @trezor/connect params getAccountInfoCalls?: number; // used in XRP @@ -126,6 +127,11 @@ const actionCallback = ( result.composeTransactionCalls, ); } + if (typeof result.solanaComposeTransactionCalls === 'number') { + expect(TrezorConnect.solanaComposeTransaction).toHaveBeenCalledTimes( + result.solanaComposeTransactionCalls, + ); + } if (typeof result.estimateFeeCalls === 'number') { expect(TrezorConnect.blockchainEstimateFee).toHaveBeenCalledTimes(result.estimateFeeCalls); } diff --git a/scripts/ci/connect-test-matrix-generator.js b/scripts/ci/connect-test-matrix-generator.js index f0373a0aa86..ba817838018 100644 --- a/scripts/ci/connect-test-matrix-generator.js +++ b/scripts/ci/connect-test-matrix-generator.js @@ -79,6 +79,12 @@ const groups = { pattern: 'methods', includeFilter: 'binanceGetAddress,binanceGetPublicKey,binanceSignTransaction', }, + solana: { + name: 'solana', + pattern: 'methods', + includeFilter: + 'solanaGetAddress,solanaGetPublicKey,solanaSignTransaction,solanaComposeTransaction', + }, }; const firmwares1 = ['1.9.0', '1-latest', '1-main']; diff --git a/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts b/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts index c6764b67d3e..9170850fbb2 100644 --- a/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts +++ b/suite-common/wallet-core/src/send/sendFormSolanaThunks.ts @@ -1,11 +1,7 @@ import { BigNumber } from '@trezor/utils/src/bigNumber'; import TrezorConnect, { FeeLevel } from '@trezor/connect'; -import type { TokenInfo, TokenAccount } from '@trezor/blockchain-link-types'; -import { - SYSTEM_PROGRAM_PUBLIC_KEY, - TokenProgramName, - tokenStandardToTokenProgramName, -} from '@trezor/blockchain-link-utils/src/solana'; +import type { TokenInfo } from '@trezor/blockchain-link-types'; +import { tokenStandardToTokenProgramName } from '@trezor/blockchain-link-utils/src/solana'; import { ExternalOutput, PrecomposedTransaction, @@ -18,12 +14,8 @@ import { calculateMax, calculateTotal, formatAmount, - getExternalComposeOutput, - buildTransferTransaction, - buildTokenTransferTransaction, - getAssociatedTokenAccountAddress, - dummyPriorityFeesForFeeEstimation, getAccountIdentity, + getExternalComposeOutput, } from '@suite-common/wallet-utils'; import { getNetworkDisplaySymbol } from '@suite-common/wallet-config'; @@ -114,39 +106,6 @@ const calculate = ( return payloadData; }; -const fetchAccountOwnerAndTokenInfoForAddress = async ( - address: string, - symbol: string, - mint: string, - tokenProgram: TokenProgramName, -) => { - // Fetch data about recipient account owner if this is a token transfer - // We need this in order to validate the address and ensure transfers go through - let accountOwner: string | undefined; - let tokenInfo: TokenAccount | undefined; - - const accountInfoResponse = await TrezorConnect.getAccountInfo({ - coin: symbol, - descriptor: address, - details: 'tokens', - }); - - if (accountInfoResponse.success) { - const associatedTokenAccount = await getAssociatedTokenAccountAddress( - address, - mint, - tokenProgram, - ); - - accountOwner = accountInfoResponse.payload?.misc?.owner; - tokenInfo = accountInfoResponse.payload?.tokens - ?.find(token => token.contract === mint) - ?.accounts?.find(account => associatedTokenAccount.toString() === account.publicKey); - } - - return [accountOwner, tokenInfo] as const; -}; - function assertIsSolanaAccount( account: Account, ): asserts account is Extract { @@ -171,19 +130,8 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk< const { output, decimals, tokenInfo } = composedOutput; - const { blockhash, blockHeight: lastValidBlockHeight } = selectBlockchainBlockInfoBySymbol( - getState(), - account.symbol, - ); - - const [recipientAccountOwner, recipientTokenAccount] = tokenInfo - ? await fetchAccountOwnerAndTokenInfoForAddress( - formState.outputs[0].address, - account.symbol, - tokenInfo.contract, - tokenStandardToTokenProgramName(tokenInfo.type), - ) - : [undefined, undefined]; + const { blockhash: blockHash, blockHeight: lastValidBlockHeight } = + selectBlockchainBlockInfoBySymbol(getState(), account.symbol); // invalid token transfer -- should never happen if (tokenInfo && !tokenInfo.accounts) @@ -201,56 +149,43 @@ export const composeSolanaTransactionFeeLevelsThunk = createThunk< } } - const tokenTransferTxAndDestinationAddress = - tokenInfo && tokenInfo.accounts - ? await buildTokenTransferTransaction( - account.descriptor, - formState.outputs[0].address || account.descriptor, - recipientAccountOwner || SYSTEM_PROGRAM_PUBLIC_KEY, - tokenInfo.contract, - formState.outputs[0].amount || '0', - tokenInfo.decimals, - tokenInfo.accounts, - recipientTokenAccount, - blockhash, - lastValidBlockHeight, - dummyPriorityFeesForFeeEstimation, - tokenStandardToTokenProgramName(tokenInfo.type), - ) - : undefined; - // To estimate fees on Solana we need to turn a transaction into a message for which fees are estimated. // Since all the values don't have to be filled in the form at the time of this function call, we use dummy values // for the estimation, since these values don't affect the final fee. // The real transaction is constructed in `signTransaction`, this one is used solely for fee estimation and is never submitted. - const transferTx = - tokenTransferTxAndDestinationAddress != null - ? tokenTransferTxAndDestinationAddress.transaction - : await buildTransferTransaction( - account.descriptor, - formState.outputs[0].address || account.descriptor, - formState.outputs[0].amount || '0', - blockhash, - lastValidBlockHeight, - dummyPriorityFeesForFeeEstimation, - ); - - const isCreatingAccount = - tokenInfo && - recipientTokenAccount === undefined && - // if the recipient account has no owner, it means it's a new account and needs the token account to be created - (recipientAccountOwner === SYSTEM_PROGRAM_PUBLIC_KEY || recipientAccountOwner == null); - const newTokenAccountProgramName = isCreatingAccount - ? tokenStandardToTokenProgramName(tokenInfo.type) - : undefined; + const transaction = await TrezorConnect.solanaComposeTransaction({ + fromAddress: account.descriptor, + toAddress: formState.outputs[0].address, + amount: formState.outputs[0].amount, + token: tokenInfo + ? { + mint: tokenInfo.contract, + program: tokenStandardToTokenProgramName(tokenInfo.type), + decimals: tokenInfo.decimals, + accounts: tokenInfo.accounts ?? [], + } + : undefined, + blockHash, + lastValidBlockHeight, + coin: account.symbol, + identity: getAccountIdentity(account), + }); + + if (!transaction.success) { + return rejectWithValue({ + error: 'fee-levels-compose-failed', + message: transaction.payload.error, + }); + } const estimatedFee = await TrezorConnect.blockchainEstimateFee({ coin: account.symbol, request: { specific: { - data: transferTx.serialize(), - isCreatingAccount, - newTokenAccountProgramName, + data: transaction.payload.serializedTx, + isCreatingAccount: transaction.payload.additionalInfo.isCreatingAccount, + newTokenAccountProgramName: + transaction.payload.additionalInfo.newTokenAccountProgramName, }, }, }); @@ -353,61 +288,40 @@ export const signSolanaSendFormTransactionThunk = createThunk< } const { blockHash, blockHeight: lastValidBlockHeight } = blockchainInfo.payload; - const [recipientAccountOwner, recipientTokenAccounts] = token - ? await fetchAccountOwnerAndTokenInfoForAddress( - formState.outputs[0].address, - selectedAccount.symbol, - token.contract, - tokenStandardToTokenProgramName(token.type), - ) - : [undefined, undefined]; - if (token && !token.accounts) rejectWithValue({ error: 'sign-transaction-failed', message: 'Missing token accounts.', }); - const tokenTransferTxAndDestinationAddress = - token && token.accounts - ? await buildTokenTransferTransaction( - selectedAccount.descriptor, - formState.outputs[0].address || selectedAccount.descriptor, - recipientAccountOwner || SYSTEM_PROGRAM_PUBLIC_KEY, - token.contract, - formState.outputs[0].amount || '0', - token.decimals, - token.accounts, - recipientTokenAccounts, - blockHash, - lastValidBlockHeight, - { - computeUnitPrice: precomposedTransaction.feePerByte, - computeUnitLimit: precomposedTransaction.feeLimit, - }, - tokenStandardToTokenProgramName(token.type), - ) - : undefined; - - if (token && !tokenTransferTxAndDestinationAddress) + const transaction = await TrezorConnect.solanaComposeTransaction({ + fromAddress: selectedAccount.descriptor, + toAddress: formState.outputs[0].address, + amount: formState.outputs[0].amount, + token: token + ? { + mint: token.contract, + program: tokenStandardToTokenProgramName(token.type), + decimals: token.decimals, + accounts: token.accounts ?? [], + } + : undefined, + blockHash, + lastValidBlockHeight, + priorityFees: { + computeUnitPrice: precomposedTransaction.feePerByte, + computeUnitLimit: precomposedTransaction.feeLimit, + }, + coin: selectedAccount.symbol, + identity: getAccountIdentity(selectedAccount), + }); + + if (!transaction.success) { return rejectWithValue({ error: 'sign-transaction-failed', - message: 'Token transfer address missing.', + message: transaction.payload.error, }); - - const tx = tokenTransferTxAndDestinationAddress - ? tokenTransferTxAndDestinationAddress.transaction - : await buildTransferTransaction( - selectedAccount.descriptor, - formState.outputs[0].address, - formState.outputs[0].amount, - blockHash, - lastValidBlockHeight, - { - computeUnitPrice: precomposedTransaction.feePerByte, - computeUnitLimit: precomposedTransaction.feeLimit, - }, - ); + } const response = await TrezorConnect.solanaSignTransaction({ device: { @@ -417,16 +331,13 @@ export const signSolanaSendFormTransactionThunk = createThunk< }, useEmptyPassphrase: device.useEmptyPassphrase, path: selectedAccount.path, - serializedTx: tx.serializeMessage(), - additionalInfo: - tokenTransferTxAndDestinationAddress && - tokenTransferTxAndDestinationAddress.tokenAccountInfo - ? { - tokenAccountsInfos: [ - tokenTransferTxAndDestinationAddress.tokenAccountInfo, - ], - } - : undefined, + serializedTx: transaction.payload.serializedTx, + serialize: true, + additionalInfo: transaction.payload.additionalInfo.tokenAccountInfo + ? { + tokenAccountsInfos: [transaction.payload.additionalInfo.tokenAccountInfo], + } + : undefined, }); if (!response.success) { @@ -438,17 +349,6 @@ export const signSolanaSendFormTransactionThunk = createThunk< }); } - try { - tx.addSignature(selectedAccount.descriptor, response.payload.signature); - const signedSerializedTx = tx.serialize(); - - return { serializedTx: signedSerializedTx }; - } catch (e) { - return rejectWithValue({ - error: 'sign-transaction-failed', - errorCode: e.code, - message: e.error, - }); - } + return { serializedTx: response.payload.serializedTx! }; }, ); diff --git a/suite-common/wallet-utils/package.json b/suite-common/wallet-utils/package.json index 45d5b7299b1..569c876ee2f 100644 --- a/suite-common/wallet-utils/package.json +++ b/suite-common/wallet-utils/package.json @@ -13,12 +13,6 @@ }, "dependencies": { "@everstake/wallet-sdk": "^1.0.7", - "@mobily/ts-belt": "^3.13.1", - "@solana-program/compute-budget": "^0.6.1", - "@solana-program/system": "^0.6.2", - "@solana-program/token": "^0.4.1", - "@solana-program/token-2022": "^0.3.1", - "@solana/web3.js": "^2.0.0", "@suite-common/fiat-services": "workspace:*", "@suite-common/metadata-types": "workspace:*", "@suite-common/suite-config": "workspace:*", diff --git a/suite-common/wallet-utils/src/__tests__/sendFormUtils.test.ts b/suite-common/wallet-utils/src/__tests__/sendFormUtils.test.ts index 9950a92ee18..e6c1e5e2e1a 100644 --- a/suite-common/wallet-utils/src/__tests__/sendFormUtils.test.ts +++ b/suite-common/wallet-utils/src/__tests__/sendFormUtils.test.ts @@ -12,7 +12,6 @@ import { getExcludedUtxos, getExternalComposeOutput, getInputState, - getLamportsFromSol, prepareEthereumTransaction, restoreOrigOutputsOrder, } from '../sendFormUtils'; @@ -393,9 +392,4 @@ describe('sendForm utils', () => { expect(excludedUtxos[getUtxoOutpoint(lowAnonymityUtxo)]).toBe('low-anonymity'); expect(excludedUtxos[getUtxoOutpoint(spendableUtxo)]).toBe(undefined); }); - - it('getLamportsFromSol', () => { - expect(getLamportsFromSol('1')).toEqual(1000000000n); - expect(getLamportsFromSol('0.000000001')).toEqual(1n); - }); }); diff --git a/suite-common/wallet-utils/src/index.ts b/suite-common/wallet-utils/src/index.ts index c92c0ebd9b3..e829b76e3bc 100644 --- a/suite-common/wallet-utils/src/index.ts +++ b/suite-common/wallet-utils/src/index.ts @@ -13,7 +13,6 @@ export * from './localizePercentage'; export * from './networkUtils'; export * from './sendFormUtils'; export * from './settingsUtils'; -export * from './solanaUtils'; export * from './transactionUtils'; export * from './validationUtils'; export * from './ethereumStakingUtils'; diff --git a/suite-common/wallet-utils/src/sendFormUtils.ts b/suite-common/wallet-utils/src/sendFormUtils.ts index 9b4e0be2320..4c962eb749c 100644 --- a/suite-common/wallet-utils/src/sendFormUtils.ts +++ b/suite-common/wallet-utils/src/sendFormUtils.ts @@ -489,8 +489,3 @@ export const getSendFormDraftKey = ( accountKey: AccountKey, tokenAddress?: TokenAddress, ): SendFormDraftKey => (tokenAddress ? `${accountKey}-${tokenAddress}` : accountKey); - -// SOL Specific - -export const getLamportsFromSol = (amountInSol: string) => - BigInt(new BigNumber(amountInSol).times(10 ** 9).toString()); diff --git a/yarn.lock b/yarn.lock index b7a32cb9523..ce31eb923b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9834,12 +9834,6 @@ __metadata: resolution: "@suite-common/wallet-utils@workspace:suite-common/wallet-utils" dependencies: "@everstake/wallet-sdk": "npm:^1.0.7" - "@mobily/ts-belt": "npm:^3.13.1" - "@solana-program/compute-budget": "npm:^0.6.1" - "@solana-program/system": "npm:^0.6.2" - "@solana-program/token": "npm:^0.4.1" - "@solana-program/token-2022": "npm:^0.3.1" - "@solana/web3.js": "npm:^2.0.0" "@suite-common/fiat-services": "workspace:*" "@suite-common/metadata-types": "workspace:*" "@suite-common/suite-config": "workspace:*" @@ -12032,10 +12026,17 @@ __metadata: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" "@fivebinaries/coin-selection": "npm:3.0.0" + "@mobily/ts-belt": "npm:^3.13.1" "@noble/hashes": "npm:^1.6.1" "@scure/bip39": "npm:^1.5.1" + "@solana-program/compute-budget": "npm:^0.6.1" + "@solana-program/system": "npm:^0.6.2" + "@solana-program/token": "npm:^0.4.1" + "@solana-program/token-2022": "npm:^0.3.1" + "@solana/web3.js": "npm:^2.0.0" "@trezor/blockchain-link": "workspace:*" "@trezor/blockchain-link-types": "workspace:*" + "@trezor/blockchain-link-utils": "workspace:*" "@trezor/connect-analytics": "workspace:*" "@trezor/connect-common": "workspace:*" "@trezor/crypto-utils": "workspace:*"