diff --git a/package.json b/package.json index 116e6aa53e..0ff438560d 100644 --- a/package.json +++ b/package.json @@ -270,7 +270,7 @@ "@leather.io/eslint-config": "0.7.0", "@leather.io/panda-preset": "0.8.0", "@leather.io/prettier-config": "0.6.0", - "@leather.io/rpc": "2.4.0", + "@leather.io/rpc": "2.5.2", "@ls-lint/ls-lint": "2.2.3", "@mdx-js/loader": "3.0.0", "@pandacss/dev": "0.46.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f4df31224..db883162a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,8 +415,8 @@ importers: specifier: 0.6.0 version: 0.6.0(@vue/compiler-sfc@3.5.13) '@leather.io/rpc': - specifier: 2.4.0 - version: 2.4.0(encoding@0.1.13) + specifier: 2.5.2 + version: 2.5.2(encoding@0.1.13) '@ls-lint/ls-lint': specifier: 2.2.3 version: 2.2.3 @@ -3320,6 +3320,9 @@ packages: '@leather.io/models@0.24.2': resolution: {integrity: sha512-DTxDbcQp5CZyh0ri4aiNurkHxORKbbj05vnyHGWSok33DZSE3j0/hcGHP9IA1sGPNO5OMt96ivINiMuKmRA8Fw==} + '@leather.io/models@0.24.3': + resolution: {integrity: sha512-vsla6ARj645vqNDyj/5TEKrRI933Aac3Pq3rOyvf3zwepJqgdmPFbkLGFa36oHpC9hLsD6iy1zUyeEp71nqmdg==} + '@leather.io/panda-preset@0.8.0': resolution: {integrity: sha512-DnDSxZ5AJPYBdykTNpTeuB4WewwWUGWPoDWQdeX4/Th5gfekUwvnybLM9D3iVCkvmUgv+BZgMgCzHnd5cGO2FA==} @@ -3343,6 +3346,9 @@ packages: '@leather.io/rpc@2.4.1': resolution: {integrity: sha512-edpoAkrBXjnQPqKRJrvUJKMxr5MbgNes1gluT3EUZuYYBAj5bzQ3ZhBz+hghX72UqaA4K5c12f7rjYMuYIPb4Q==} + '@leather.io/rpc@2.5.2': + resolution: {integrity: sha512-1T4GpCjKGeFsfBDZ+jXHL2Gn3cNasTwG3fzHvBM0eg5PQPA/OZVva73iWYDULEbYZFYeyHOsMFEStRuI7yluLw==} + '@leather.io/stacks@1.4.0': resolution: {integrity: sha512-vF3eQljr+dsfg8DhlEFgQKvr9NHn9CKwt8XT51kWnULTtZH6syrABiarHGwhtE/AZz9weg5n5q/+m8b4lN6bGw==} @@ -19218,6 +19224,12 @@ snapshots: bignumber.js: 9.1.2 zod: 3.23.8 + '@leather.io/models@0.24.3': + dependencies: + '@stacks/stacks-blockchain-api-types': 7.8.2 + bignumber.js: 9.1.2 + zod: 3.23.8 + '@leather.io/panda-preset@0.8.0(jsdom@22.1.0)(typescript@5.4.5)': dependencies: '@pandacss/dev': 0.46.1(jsdom@22.1.0)(typescript@5.4.5) @@ -19292,6 +19304,15 @@ snapshots: transitivePeerDependencies: - encoding + '@leather.io/rpc@2.5.2(encoding@0.1.13)': + dependencies: + '@leather.io/models': 0.24.3 + '@stacks/network': 6.13.0(encoding@0.1.13) + '@stacks/transactions-v7': '@stacks/transactions@7.0.2(encoding@0.1.13)' + zod: 3.23.8 + transitivePeerDependencies: + - encoding + '@leather.io/stacks@1.4.0(encoding@0.1.13)': dependencies: '@leather.io/constants': 0.13.5 diff --git a/src/app/features/stacks-transaction-request/stacks-transaction-signer.tsx b/src/app/features/stacks-transaction-request/stacks-transaction-signer.tsx index 9d5e3a685c..7367111cdb 100644 --- a/src/app/features/stacks-transaction-request/stacks-transaction-signer.tsx +++ b/src/app/features/stacks-transaction-request/stacks-transaction-signer.tsx @@ -8,6 +8,7 @@ import * as yup from 'yup'; import { HIGH_FEE_WARNING_LEARN_MORE_URL_STX } from '@leather.io/constants'; import { FeeTypes } from '@leather.io/models'; import { + defaultStacksFees, useCalculateStacksTxFees, useNextNonce, useStxCryptoAssetBalance, @@ -40,7 +41,7 @@ import { MinimalErrorMessage } from './minimal-error-message'; import { StacksTxSubmitAction } from './submit-action'; interface StacksTransactionSignerProps { - stacksTransaction: StacksTransaction; + stacksTransaction?: StacksTransaction; disableFeeSelection?: boolean; disableNonceSelection?: boolean; isMultisig: boolean; @@ -123,7 +124,7 @@ export function StacksTransactionSigner({ {!isNonceAlreadySet && } + + + ); +} diff --git a/src/app/pages/rpc-stx-call-contract/use-rpc-stx-call-contract.ts b/src/app/pages/rpc-stx-call-contract/use-rpc-stx-call-contract.ts new file mode 100644 index 0000000000..d03902306a --- /dev/null +++ b/src/app/pages/rpc-stx-call-contract/use-rpc-stx-call-contract.ts @@ -0,0 +1,107 @@ +import { useMemo } from 'react'; +import { useAsync } from 'react-async-hook'; + +import { bytesToHex } from '@stacks/common'; +import type { TransactionPayload } from '@stacks/connect'; + +import { RpcErrorCode } from '@leather.io/rpc'; + +import { logger } from '@shared/logger'; +import { makeRpcErrorResponse, makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; +import { closeWindow } from '@shared/utils'; +import { getPayloadFromToken } from '@shared/utils/requests'; + +import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-search-params'; +import { initialSearchParams } from '@app/common/initial-search-params'; +import { + type GenerateUnsignedTransactionOptions, + generateUnsignedTransaction, +} from '@app/common/transactions/stacks/generate-unsigned-txs'; +import { getTxSenderAddress } from '@app/common/transactions/stacks/transaction.utils'; +import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; +import { useSignStacksTransaction } from '@app/store/transactions/transaction.hooks'; + +function useRpcStxCallContractParams() { + const { origin, tabId } = useDefaultRequestParams(); + const requestId = initialSearchParams.get('requestId'); + const request = initialSearchParams.get('request'); + + if (!origin || !request || !requestId) throw new Error('Invalid params'); + + return useMemo( + () => ({ + origin, + tabId: tabId ?? 0, + request: getPayloadFromToken(request), + requestId, + }), + [origin, tabId, request, requestId] + ); +} + +function useUnsignedStacksTransactionFromRequest(request: TransactionPayload) { + const account = useCurrentStacksAccount(); + + const tx = useAsync(async () => { + if (!account) return; + + const options: GenerateUnsignedTransactionOptions = { + publicKey: account.stxPublicKey, + txData: request, + fee: request.fee ?? 0, + nonce: request.nonce, + }; + return generateUnsignedTransaction(options); + }, [account]); + + return tx.result; +} + +export function useRpcStxCallContract() { + const { origin, request, requestId, tabId } = useRpcStxCallContractParams(); + const signStacksTx = useSignStacksTransaction(); + const stacksTransaction = useUnsignedStacksTransactionFromRequest(request); + + return { + origin, + txSender: stacksTransaction ? getTxSenderAddress(stacksTransaction) : '', + stacksTransaction, + async onSignStacksTransaction(fee: number, nonce: number) { + if (!stacksTransaction) { + return logger.error('No stacks transaction to sign'); + } + + stacksTransaction.setFee(fee); + stacksTransaction.setNonce(nonce); + + const signedTransaction = await signStacksTx(stacksTransaction); + if (!signedTransaction) { + throw new Error('Error signing stacks transaction'); + } + + chrome.tabs.sendMessage( + tabId, + makeRpcSuccessResponse('stx_callContract', { + id: requestId, + result: { + txid: '', // Broadcast transaction? + transaction: bytesToHex(signedTransaction.serialize()), + } as any, // Fix this + }) + ); + closeWindow(); + }, + onCancel() { + chrome.tabs.sendMessage( + tabId, + makeRpcErrorResponse('stx_callContract', { + id: requestId, + error: { + message: 'User denied signing stacks transaction', + code: RpcErrorCode.USER_REJECTION, + }, + }) + ); + }, + }; +} diff --git a/src/app/routes/rpc-routes.tsx b/src/app/routes/rpc-routes.tsx index 23cdc38aed..d5dde60ddb 100644 --- a/src/app/routes/rpc-routes.tsx +++ b/src/app/routes/rpc-routes.tsx @@ -13,6 +13,7 @@ import { RpcSignPsbt } from '@app/pages/rpc-sign-psbt/rpc-sign-psbt'; import { RpcSignPsbtSummary } from '@app/pages/rpc-sign-psbt/rpc-sign-psbt-summary'; import { RpcStacksMessageSigning } from '@app/pages/rpc-sign-stacks-message/rpc-sign-stacks-message'; import { RpcSignStacksTransaction } from '@app/pages/rpc-sign-stacks-transaction/rpc-sign-stacks-transaction'; +import { RpcStxCallContract } from '@app/pages/rpc-stx-call-contract/rpc-stx-call-contract'; import { AccountGate } from '@app/routes/account-gate'; import { SuspenseLoadingSpinner } from './app-routes'; @@ -83,5 +84,17 @@ export const rpcRequestRoutes = ( {ledgerStacksTxSigningRoutes} } /> + + + + + } + > + {ledgerStacksTxSigningRoutes} + } /> + ); diff --git a/src/background/messaging/messaging-utils.ts b/src/background/messaging/messaging-utils.ts index 25019dab80..e8cd68fa63 100644 --- a/src/background/messaging/messaging-utils.ts +++ b/src/background/messaging/messaging-utils.ts @@ -1,5 +1,8 @@ import type { To } from 'react-router-dom'; +import { bytesToHex } from '@stacks/common'; +import { type PostCondition, serializePostCondition } from '@stacks/transactions'; + import { InternalMethods } from '@shared/message-types'; import { sendMessage } from '@shared/messages'; import { RouteUrls } from '@shared/route-urls'; @@ -65,6 +68,10 @@ export function makeSearchParamsWithDefaults( return { urlParams, origin, tabId }; } +export function encodePostConditions(postConditions: PostCondition[]) { + return postConditions.map(pc => bytesToHex(serializePostCondition(pc))); +} + const IS_TEST_ENV = process.env.TEST_ENV === 'true'; export async function triggerRequestWindowOpen(path: RouteUrls, urlParams: URLSearchParams) { diff --git a/src/background/messaging/rpc-message-handler.ts b/src/background/messaging/rpc-message-handler.ts index 4f1a365323..b023cb0369 100644 --- a/src/background/messaging/rpc-message-handler.ts +++ b/src/background/messaging/rpc-message-handler.ts @@ -13,6 +13,7 @@ import { rpcSendTransfer } from './rpc-methods/send-transfer'; import { rpcSignMessage } from './rpc-methods/sign-message'; import { rpcSignPsbt } from './rpc-methods/sign-psbt'; import { rpcSignStacksMessage } from './rpc-methods/sign-stacks-message'; +import { rpcStxCallContract } from './rpc-methods/stx-call-contract'; import { rpcSupportedMethods } from './rpc-methods/supported-methods'; export async function rpcMessageHandler(message: WalletRequests, port: chrome.runtime.Port) { @@ -47,6 +48,11 @@ export async function rpcMessageHandler(message: WalletRequests, port: chrome.ru break; } + case 'stx_callContract': { + await rpcStxCallContract(message, port); + break; + } + case 'stx_signTransaction': { await rpcSignStacksTransaction(message, port); break; diff --git a/src/background/messaging/rpc-methods/stx-call-contract.ts b/src/background/messaging/rpc-methods/stx-call-contract.ts new file mode 100644 index 0000000000..db9ea326c2 --- /dev/null +++ b/src/background/messaging/rpc-methods/stx-call-contract.ts @@ -0,0 +1,124 @@ +import { TransactionTypes } from '@stacks/connect'; +import { type ClarityValue, type PostCondition, serializeCV } from '@stacks/transactions'; +import { createUnsecuredToken } from 'jsontokens'; + +import { + RpcErrorCode, + type StxCallContractRequest, + type StxCallContractRequestParams, +} from '@leather.io/rpc'; +import { getStacksContractIdStringParts } from '@leather.io/stacks'; +import { isDefined, isUndefined } from '@leather.io/utils'; + +import { RouteUrls } from '@shared/route-urls'; +import { + getRpcStxCallContractParamErrors, + validateRpcStxCallContractParams, +} from '@shared/rpc/methods/stx-call-contract'; +import { makeRpcErrorResponse } from '@shared/rpc/rpc-methods'; + +import { + RequestParams, + encodePostConditions, + getTabIdFromPort, + listenForPopupClose, + makeSearchParamsWithDefaults, + triggerRequestWindowOpen, +} from '../messaging-utils'; +import { trackRpcRequestError, trackRpcRequestSuccess } from '../rpc-message-handler'; + +// TODO: Share SIP-30 default params +const messageParamsToTransactionRequest = (params: StxCallContractRequestParams) => { + const { contractAddress, contractName } = getStacksContractIdStringParts(params.contract); + + const transactionRequest = { + txType: TransactionTypes.ContractCall, + contractAddress, + contractName, + functionArgs: (params.functionArgs ?? []).map(arg => + Buffer.from(serializeCV(arg as unknown as ClarityValue)).toString('hex') + ), + functionName: params.functionName, + } as any; + + if (isDefined(params.address)) { + transactionRequest.stxAddress = params.address; + } + if (isDefined(params.fee)) { + transactionRequest.fee = params.fee; + } + if (isDefined(params.nonce)) { + transactionRequest.nonce = params.nonce; + } + if (isDefined(params.postConditions)) { + transactionRequest.postConditions = encodePostConditions( + params.postConditions as PostCondition[] + ); + } + if (isDefined(params.postConditionMode)) { + transactionRequest.postConditionMode = params.postConditionMode; + } + if (isDefined(params.sponsored)) { + transactionRequest.sponsored = params.sponsored; + } + + return transactionRequest; +}; + +export async function rpcStxCallContract( + message: StxCallContractRequest, + port: chrome.runtime.Port +) { + if (isUndefined(message.params)) { + void trackRpcRequestError({ endpoint: message.method, error: 'Undefined parameters' }); + chrome.tabs.sendMessage( + getTabIdFromPort(port), + makeRpcErrorResponse('stx_callContract', { + id: message.id, + error: { code: RpcErrorCode.INVALID_REQUEST, message: 'Parameters undefined' }, + }) + ); + return; + } + + if (!validateRpcStxCallContractParams(message.params)) { + void trackRpcRequestError({ endpoint: message.method, error: 'Invalid parameters' }); + + chrome.tabs.sendMessage( + getTabIdFromPort(port), + makeRpcErrorResponse('stx_callContract', { + id: message.id, + error: { + code: RpcErrorCode.INVALID_PARAMS, + message: getRpcStxCallContractParamErrors(message.params), + }, + }) + ); + return; + } + + const request = messageParamsToTransactionRequest(message.params); + + void trackRpcRequestSuccess({ endpoint: message.method }); + + const requestParams: RequestParams = [ + ['requestId', message.id], + ['request', createUnsecuredToken(request)], + ]; + + const { urlParams, tabId } = makeSearchParamsWithDefaults(port, requestParams); + + const { id } = await triggerRequestWindowOpen(RouteUrls.RpcStxCallContract, urlParams); + + listenForPopupClose({ + tabId, + id, + response: makeRpcErrorResponse('stx_callContract', { + id: message.id, + error: { + code: RpcErrorCode.USER_REJECTION, + message: 'User denied signing stacks transaction', + }, + }), + }); +} diff --git a/src/shared/route-urls.ts b/src/shared/route-urls.ts index 12cdcc88df..960e0f62ea 100644 --- a/src/shared/route-urls.ts +++ b/src/shared/route-urls.ts @@ -105,4 +105,5 @@ export enum RouteUrls { // Request routes stacks RpcSignStacksTransaction = '/sign-stacks-transaction', + RpcStxCallContract = '/stx-call-contract', } diff --git a/src/shared/rpc/methods/stx-call-contract.ts b/src/shared/rpc/methods/stx-call-contract.ts new file mode 100644 index 0000000000..69b8651005 --- /dev/null +++ b/src/shared/rpc/methods/stx-call-contract.ts @@ -0,0 +1,11 @@ +import { stxCallContractRequestParamsSchema } from '@leather.io/rpc'; + +import { formatValidationErrors, getRpcParamErrors, validateRpcParams } from './validation.utils'; + +export function validateRpcStxCallContractParams(obj: unknown) { + return validateRpcParams(obj, stxCallContractRequestParamsSchema); +} + +export function getRpcStxCallContractParamErrors(obj: unknown) { + return formatValidationErrors(getRpcParamErrors(obj, stxCallContractRequestParamsSchema)); +}