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 000000000000..b1c63bbfe922 --- /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/e2e/__fixtures__/index.ts b/packages/connect/e2e/__fixtures__/index.ts index f9df6ba282ad..66a8af65409e 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 000000000000..d570791679ac --- /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/src/api/solana/api/index.ts b/packages/connect/src/api/solana/api/index.ts index 163de9125502..281e228608dd 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 000000000000..831c7878b730 --- /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/solanaUtils.ts b/packages/connect/src/api/solana/solanaUtils.ts index b20d214e3d3a..fdf0ea4b17fb 100644 --- a/packages/connect/src/api/solana/solanaUtils.ts +++ b/packages/connect/src/api/solana/solanaUtils.ts @@ -82,12 +82,14 @@ export async function createTransactionShim(message: CompilableTransactionMessag } export async function createTransactionShimFromHex(rawTx: string) { - const { getBase16Encoder, getTransactionDecoder } = await loadSolanaLib(); + const { getBase16Encoder, getCompiledTransactionMessageDecoder, decompileTransactionMessage } = + await loadSolanaLib(); const txByteArray = getBase16Encoder().encode(rawTx); - const transaction = getTransactionDecoder().decode(txByteArray); + const compiledMessage = getCompiledTransactionMessageDecoder().decode(txByteArray); + const message = decompileTransactionMessage(compiledMessage); - return createTransactionShimCommon(transaction); + return createTransactionShim(message); } const addPriorityFees = async ( diff --git a/packages/connect/src/factory.ts b/packages/connect/src/factory.ts index 30083916adaf..29ed52a538f1 100644 --- a/packages/connect/src/factory.ts +++ b/packages/connect/src/factory.ts @@ -172,6 +172,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 12900e124d63..cba08692500d 100644 --- a/packages/connect/src/types/api/index.ts +++ b/packages/connect/src/types/api/index.ts @@ -72,6 +72,7 @@ import { setProxy } from './setProxy'; 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'; @@ -308,6 +309,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 f1498e783599..9b6decfc51ee 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 000000000000..c0926eb7aa3a --- /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;