diff --git a/packages/kit-bg/src/services/ServiceSend.broadcastDeadline.test.ts b/packages/kit-bg/src/services/ServiceSend.broadcastDeadline.test.ts index c15b456b1839..8a63ab13dfab 100644 --- a/packages/kit-bg/src/services/ServiceSend.broadcastDeadline.test.ts +++ b/packages/kit-bg/src/services/ServiceSend.broadcastDeadline.test.ts @@ -199,6 +199,9 @@ function makeService() { buildDecodedTx: jest.fn().mockResolvedValue(decodedTx), broadcastTransaction: jest.fn().mockResolvedValue({ txid: '0xtxid' }), checkShouldRetryBroadcastTx: jest.fn().mockResolvedValue(false), + refreshUnsignedTxBeforeBatchSign: jest.fn((tx: IUnsignedTxPro) => + Promise.resolve(tx), + ), }; (vaultFactory.getVault as unknown as jest.Mock).mockResolvedValue(vault); @@ -1015,6 +1018,78 @@ describe('ServiceSend.signAndSendTransaction broadcastDeadline', () => { ); }); + test('threads Gas Account state through a single-tx Private Send', async () => { + const { service } = makeService(); + const signAndSendSpy = jest + .spyOn(service, 'signAndSendTransaction') + .mockResolvedValue(signedTx); + jest + .spyOn(service, 'buildDecodedTx') + .mockResolvedValue({ actions: [] } as unknown as IDecodedTx); + const gasAccountUiState: IGasAccountUiState = { + selectedPayer: 'gasAccount', + gasAccountQuote: { + quoteId: 'quote-id', + maxFee: '1', + expiresAt: '1970-01-01T00:00:01.000Z', + }, + idempotencyKey: 'gas-account:quote-id', + }; + + await service.batchSignAndSendTransaction({ + accountId, + networkId, + unsignedTxs: [unsignedTx], + signOnly: false, + transferPayload: { isPrivateSend: true } as ITransferPayload, + gasAccountUiState, + gasAccountSubmitId: 'submit-id', + }); + + expect(signAndSendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + gasAccountUiState, + gasAccountSubmitId: 'submit-id', + isPrivateSend: true, + }), + ); + }); + + test('still strips Gas Account state for multi-tx batches', async () => { + const { service } = makeService(); + const signAndSendSpy = jest + .spyOn(service, 'signAndSendTransaction') + .mockResolvedValue(signedTx); + jest + .spyOn(service, 'buildDecodedTx') + .mockResolvedValue({ actions: [] } as unknown as IDecodedTx); + + await service.batchSignAndSendTransaction({ + accountId, + networkId, + unsignedTxs: [unsignedTx, unsignedTx], + signOnly: false, + transferPayload: undefined, + gasAccountUiState: { + selectedPayer: 'gasAccount', + gasAccountQuote: { + quoteId: 'quote-id', + maxFee: '1', + expiresAt: '1970-01-01T00:00:01.000Z', + }, + }, + gasAccountSubmitId: 'submit-id', + }); + + expect(signAndSendSpy).toHaveBeenCalledTimes(2); + expect(signAndSendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + gasAccountUiState: undefined, + gasAccountSubmitId: undefined, + }), + ); + }); + test('rejects an Infini before-broadcast action for a transaction batch', async () => { const { service, vault } = makeService(); diff --git a/packages/kit-bg/src/services/ServiceSend.ts b/packages/kit-bg/src/services/ServiceSend.ts index c747754d0010..3456465fbaf2 100644 --- a/packages/kit-bg/src/services/ServiceSend.ts +++ b/packages/kit-bg/src/services/ServiceSend.ts @@ -815,15 +815,17 @@ class ServiceSend extends ServiceBase { // A Gas Account quote is bound to a single user tx (payloadHash + locked // nonce). In batch flows every iteration would otherwise reuse the same - // quoteId/idempotencyKey. Private Send is also explicitly excluded from - // Gas Account, so sponsor state must not be threaded into submit. - const effectiveGasAccountUiState = - isMultiTxs || isPrivateSend ? undefined : gasAccountUiState; + // quoteId/idempotencyKey. Private Send is always a single deposit + // transfer, so its sponsor state passes through (OK-59993). + const effectiveGasAccountUiState = isMultiTxs + ? undefined + : gasAccountUiState; // Only thread the submitId through when we're actually going to engage the // retry loop, to avoid registering a controller for paths that will never // abort it. - const effectiveGasAccountSubmitId = - isMultiTxs || isPrivateSend ? undefined : gasAccountSubmitId; + const effectiveGasAccountSubmitId = isMultiTxs + ? undefined + : gasAccountSubmitId; // Replace (speed up / cancel) txs reuse the original pending tx's nonce. // Re-validate that nonce against the on-chain nonce at the last moment diff --git a/packages/kit/src/hooks/useSignatureConfirm.test.tsx b/packages/kit/src/hooks/useSignatureConfirm.test.tsx index 3657c9e50aec..6b6aeb2788df 100644 --- a/packages/kit/src/hooks/useSignatureConfirm.test.tsx +++ b/packages/kit/src/hooks/useSignatureConfirm.test.tsx @@ -67,7 +67,7 @@ describe('useSignatureConfirm', () => { EModalSignatureConfirmRoutes.TxConfirm, expect.objectContaining({ unsignedTxs: [unsignedTx], - gasAccountScenario: 'swap', + gasAccountScenario: 'privateSend', transferPayload: expect.objectContaining({ isPrivateSend: true }), }), ); diff --git a/packages/kit/src/hooks/useSignatureConfirm.ts b/packages/kit/src/hooks/useSignatureConfirm.ts index 42dabd47d804..4b8b288017da 100644 --- a/packages/kit/src/hooks/useSignatureConfirm.ts +++ b/packages/kit/src/hooks/useSignatureConfirm.ts @@ -67,7 +67,8 @@ type IBuildUnsignedTxParams = { isInternalTransfer?: boolean; disableMev?: boolean; // Gas Account scenario code for backend scenario gate. - // When omitted, resolved from stakingInfo/swapInfo/isInternalSwap flags; defaults to 'send'. + // When omitted, resolved from transferPayload.isPrivateSend and + // stakingInfo/swapInfo/isInternalSwap flags; defaults to 'send'. // Callers with scenarios not derivable from those flags (perps, dapp) must set it explicitly. gasAccountScenario?: IGasAccountScenario; }; @@ -76,6 +77,9 @@ function resolveGasAccountScenario( params: IBuildUnsignedTxParams, ): IGasAccountScenario { if (params.gasAccountScenario) return params.gasAccountScenario; + // Private Send rides the internal-swap pipeline (isInternalSwap=true), so + // this branch must run before the swap one. + if (params.transferPayload?.isPrivateSend) return 'privateSend'; if (params.isInternalSwap || params.swapInfo) return 'swap'; if (params.stakingInfo) return 'earn'; return 'send'; diff --git a/packages/kit/src/views/Send/pages/SendAmountInput/SendAmountInputContainer.tsx b/packages/kit/src/views/Send/pages/SendAmountInput/SendAmountInputContainer.tsx index 5c876418a9ba..0b7b20fc12dc 100644 --- a/packages/kit/src/views/Send/pages/SendAmountInput/SendAmountInputContainer.tsx +++ b/packages/kit/src/views/Send/pages/SendAmountInput/SendAmountInputContainer.tsx @@ -3113,6 +3113,9 @@ function SendAmountInputContainer() { instantRate: normalizedBuildSwapRes.result.instantRate ?? '', provider: privateSendProviderInfo, oneKeyFee: normalizedBuildSwapRes.result.fee?.percentageFee, + isFreeNetworkFee: + data?.[0]?.isNetworkFeeSponsored ?? + normalizedBuildSwapRes.result.fee?.isFreeNetworkFee, protocolFee: normalizedBuildSwapRes.result.fee?.protocolFees, otherFeeInfos: normalizedBuildSwapRes.result.fee?.otherFeeInfos ?? [], diff --git a/packages/kit/src/views/SignatureConfirm/components/SignatureConfirmActions/TxConfirmActions.tsx b/packages/kit/src/views/SignatureConfirm/components/SignatureConfirmActions/TxConfirmActions.tsx index fc9917635086..7904b9fb91e1 100644 --- a/packages/kit/src/views/SignatureConfirm/components/SignatureConfirmActions/TxConfirmActions.tsx +++ b/packages/kit/src/views/SignatureConfirm/components/SignatureConfirmActions/TxConfirmActions.tsx @@ -210,7 +210,6 @@ function TxConfirmActions(props: IProps) { const gasAccountAnalyticsContext = useGasAccountAnalyticsContext({ networkId, gasAccountScenario, - isPrivateSend: transferPayload?.isPrivateSend === true, }); const gasAccountActionSessionKey = unsignedTx?.uuid ?? diff --git a/packages/kit/src/views/SignatureConfirm/components/SignatureConfirmAlert/TxConfirmAlert.tsx b/packages/kit/src/views/SignatureConfirm/components/SignatureConfirmAlert/TxConfirmAlert.tsx index 9727d2707058..7d80c38513f9 100644 --- a/packages/kit/src/views/SignatureConfirm/components/SignatureConfirmAlert/TxConfirmAlert.tsx +++ b/packages/kit/src/views/SignatureConfirm/components/SignatureConfirmAlert/TxConfirmAlert.tsx @@ -63,7 +63,6 @@ function TxConfirmAlert(props: IProps) { const gasAccountAnalyticsContext = useGasAccountAnalyticsContext({ networkId, gasAccountScenario, - isPrivateSend: transferPayload?.isPrivateSend === true, }); // Single source of truth for the token-fee alert gate so logging and diff --git a/packages/kit/src/views/SignatureConfirm/components/TxFee/TxFeeInfo.tsx b/packages/kit/src/views/SignatureConfirm/components/TxFee/TxFeeInfo.tsx index 456fd16ebd99..7802e99178da 100644 --- a/packages/kit/src/views/SignatureConfirm/components/TxFee/TxFeeInfo.tsx +++ b/packages/kit/src/views/SignatureConfirm/components/TxFee/TxFeeInfo.tsx @@ -504,9 +504,7 @@ function TxFeeInfo(props: IProps) { transfersInfo: unsignedTxs[0].transfersInfo, lockedUserNonce, gasAccountEnabled: - !gasAccountDisabledByScenario && - !isPrivateSendTransfer && - !gasAccountTemporarilyDisabled, + !gasAccountDisabledByScenario && !gasAccountTemporarilyDisabled, scenario: gasAccountDisabledByScenario ? undefined : gasAccountScenario, @@ -551,9 +549,13 @@ function TxFeeInfo(props: IProps) { // `gasAccountUiState` for any batch (a quote is bound to one user tx // via payloadHash + locked nonce). Surfacing sponsor UI here would // show "0 network fee" / sponsor badge while the actual broadcast - // falls back to user-paid. Private Send is also user-paid by contract. + // falls back to user-paid. const sponsorDisabledForBatch = isMultiTxs; - const sponsorDisabledForPrivateSend = isPrivateSendTransfer; + // Private Send supports Gas Account sponsorship (OK-59993, admitted + // by the backend via scenario='privateSend'), but megafuel stays + // disabled for it: megafuel is an independent BNB-chain sponsor with + // no Private Send contract on the backend side. + const megafuelDisabledForPrivateSend = isPrivateSendTransfer; // `gasAccountTemporarilyDisabled` narrows only the gas-account path. // Megafuel is an independent sponsor mechanism and should still be // honored when the server indicates `payer='megafuel'`, even when a @@ -562,7 +564,7 @@ function TxFeeInfo(props: IProps) { const nextEffectiveFeePayer: IGasPayer = isCustomRpcEnabled || sponsorDisabledForBatch || - sponsorDisabledForPrivateSend || + (megafuelDisabledForPrivateSend && serverPayer === 'megafuel') || (gasAccountDisabledByScenario && serverPayer === 'gasAccount') || (gasAccountTemporarilyDisabled && serverPayer === 'gasAccount') ? 'user' @@ -572,7 +574,7 @@ function TxFeeInfo(props: IProps) { if ( r.megafuelEligible && !sponsorDisabledForBatch && - !sponsorDisabledForPrivateSend + !megafuelDisabledForPrivateSend ) { // if custom rpc is enabled, disable megafuel eligible if (isCustomRpcEnabled) { @@ -589,7 +591,7 @@ function TxFeeInfo(props: IProps) { updateMegafuelEligible(r.megafuelEligible); } } else { - if (sponsorDisabledForBatch || sponsorDisabledForPrivateSend) { + if (sponsorDisabledForBatch || megafuelDisabledForPrivateSend) { r.megafuelEligible = undefined; r.gas = r.gas?.map((gas) => ({ ...gas, @@ -603,7 +605,6 @@ function TxFeeInfo(props: IProps) { isCustomRpcEnabled || gasAccountTemporarilyDisabled || sponsorDisabledForBatch || - sponsorDisabledForPrivateSend || gasAccountDisabledByScenario ) { resetGasAccountUiState(); @@ -615,7 +616,6 @@ function TxFeeInfo(props: IProps) { } else if ( gasAccountTemporarilyDisabled || sponsorDisabledForBatch || - sponsorDisabledForPrivateSend || gasAccountDisabledByScenario ) { // The default state already flags `selectedPayer='user'`, diff --git a/packages/kit/src/views/SignatureConfirm/hooks/useGasAccountAnalyticsContext.ts b/packages/kit/src/views/SignatureConfirm/hooks/useGasAccountAnalyticsContext.ts index 2110ce61ddea..9f6d471eadff 100644 --- a/packages/kit/src/views/SignatureConfirm/hooks/useGasAccountAnalyticsContext.ts +++ b/packages/kit/src/views/SignatureConfirm/hooks/useGasAccountAnalyticsContext.ts @@ -27,11 +27,9 @@ import { buildGasAccountAnalyticsContext } from '../utils/gasAccountAnalytics'; export function useGasAccountAnalyticsContext({ networkId, gasAccountScenario, - isPrivateSend, }: { networkId: string; gasAccountScenario: IGasAccountScenario | undefined; - isPrivateSend: boolean; }): IGasAccountAnalyticsContext | undefined { const [settings] = useSettingsPersistAtom(); const [txFeeInfoInit] = useTxFeeInfoInitAtom(); @@ -63,11 +61,11 @@ export function useGasAccountAnalyticsContext({ ); const disabledForBatch = unsignedTxs.length > 1; const disabledByCustomRpc = gasAccountUiState.sponsorDisabledByCustomRpc; + // Private Send is intentionally absent here: Gas Account sponsorship is + // enabled for it (OK-59993); when no quote arrives the backend scenario + // reason reports the actual cause. const clientUnsupported = - disabledByScenario || - isPrivateSend || - disabledForBatch || - disabledByCustomRpc; + disabledByScenario || disabledForBatch || disabledByCustomRpc; const gasAccountRequested = settings.useGasAccountByDefault !== false && !clientUnsupported && @@ -95,8 +93,6 @@ export function useGasAccountAnalyticsContext({ unavailableReason = 'customRpcEnabled'; } else if (disabledByScenario) { unavailableReason = 'unsupportedScenario'; - } else if (isPrivateSend) { - unavailableReason = 'privateSend'; } else if (disabledForBatch) { unavailableReason = 'batchTransaction'; } else if (gasAccountTemporarilyDisabled) { @@ -143,7 +139,6 @@ export function useGasAccountAnalyticsContext({ gasAccountUiState.gasAccountScenarioReason, gasAccountUiState.selectedPayer, gasAccountUiState.sponsorDisabledByCustomRpc, - isPrivateSend, nativeTokenInfo.balance, nativeTokenInfo.isLoading, nativeTokenTransferAmountToUpdate.amountToUpdate, diff --git a/packages/shared/types/fee.ts b/packages/shared/types/fee.ts index 15384b7e0fb4..94ded5666e60 100644 --- a/packages/shared/types/fee.ts +++ b/packages/shared/types/fee.ts @@ -184,6 +184,7 @@ export const GAS_ACCOUNT_SCENARIOS = [ 'perps', 'earn', 'dapp', + 'privateSend', ] as const; // Frontend-only scenario codes that intentionally opt out of Gas Account. // These must not be sent to backend estimate-fee as scenario values.