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));
+}