Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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();

Expand Down
14 changes: 8 additions & 6 deletions packages/kit-bg/src/services/ServiceSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/hooks/useSignatureConfirm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('useSignatureConfirm', () => {
EModalSignatureConfirmRoutes.TxConfirm,
expect.objectContaining({
unsignedTxs: [unsignedTx],
gasAccountScenario: 'swap',
gasAccountScenario: 'privateSend',
transferPayload: expect.objectContaining({ isPrivateSend: true }),
}),
);
Expand Down
6 changes: 5 additions & 1 deletion packages/kit/src/hooks/useSignatureConfirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -504,9 +504,7 @@ function TxFeeInfo(props: IProps) {
transfersInfo: unsignedTxs[0].transfersInfo,
lockedUserNonce,
gasAccountEnabled:
!gasAccountDisabledByScenario &&
!isPrivateSendTransfer &&
!gasAccountTemporarilyDisabled,
!gasAccountDisabledByScenario && !gasAccountTemporarilyDisabled,
scenario: gasAccountDisabledByScenario
? undefined
: gasAccountScenario,
Expand Down Expand Up @@ -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;
Comment thread
weatherstar marked this conversation as resolved.
// `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
Expand All @@ -562,7 +564,7 @@ function TxFeeInfo(props: IProps) {
const nextEffectiveFeePayer: IGasPayer =
isCustomRpcEnabled ||
sponsorDisabledForBatch ||
sponsorDisabledForPrivateSend ||
(megafuelDisabledForPrivateSend && serverPayer === 'megafuel') ||
(gasAccountDisabledByScenario && serverPayer === 'gasAccount') ||
(gasAccountTemporarilyDisabled && serverPayer === 'gasAccount')
? 'user'
Expand All @@ -572,7 +574,7 @@ function TxFeeInfo(props: IProps) {
if (
r.megafuelEligible &&
!sponsorDisabledForBatch &&
!sponsorDisabledForPrivateSend
!megafuelDisabledForPrivateSend
) {
// if custom rpc is enabled, disable megafuel eligible
if (isCustomRpcEnabled) {
Expand All @@ -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,
Expand All @@ -603,7 +605,6 @@ function TxFeeInfo(props: IProps) {
isCustomRpcEnabled ||
gasAccountTemporarilyDisabled ||
sponsorDisabledForBatch ||
sponsorDisabledForPrivateSend ||
gasAccountDisabledByScenario
) {
resetGasAccountUiState();
Expand All @@ -615,7 +616,6 @@ function TxFeeInfo(props: IProps) {
} else if (
gasAccountTemporarilyDisabled ||
sponsorDisabledForBatch ||
sponsorDisabledForPrivateSend ||
gasAccountDisabledByScenario
) {
// The default state already flags `selectedPayer='user'`,
Expand Down
1 change: 1 addition & 0 deletions packages/shared/types/fee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading