diff --git a/.changeset/fee-payer-policy-config.md b/.changeset/fee-payer-policy-config.md new file mode 100644 index 00000000..542c47dc --- /dev/null +++ b/.changeset/fee-payer-policy-config.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Make Tempo charge fee-sponsorship policy resolve per chain and allow overriding it with `feePayerPolicy`. diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index b310b464..26401452 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -44,7 +44,9 @@ jobs: fi - name: Audit dependencies - run: pnpm audit + # npm's legacy audit endpoint is returning 410 Gone. Keep the audit + # check enabled, but don't fail CI on registry-level audit outages. + run: pnpm audit --ignore-registry-errors - name: Lint & format run: pnpm check:ci @@ -117,7 +119,7 @@ jobs: test-html: name: Test HTML runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Clone repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/src/tempo/internal/fee-payer.test.ts b/src/tempo/internal/fee-payer.test.ts index 0570b75f..e6eb4c11 100644 --- a/src/tempo/internal/fee-payer.test.ts +++ b/src/tempo/internal/fee-payer.test.ts @@ -285,6 +285,80 @@ describe('prepareSponsoredTransaction', () => { ).not.toThrow() }) + test('accepts higher Moderato priority fees by default', () => { + expect(() => + prepareSponsoredTransaction({ + account: sponsor, + chainId: 42431, + details, + expectedFeeToken: bogus, + transaction: { + ...baseTransaction, + gas: 626_497n, + maxFeePerGas: 24_000_000_000n, + maxPriorityFeePerGas: 24_000_000_000n, + } as any, + }), + ).not.toThrow() + }) + + test('accepts fee-payer policy overrides', () => { + expect(() => + prepareSponsoredTransaction({ + account: sponsor, + chainId: 4217, + details, + expectedFeeToken: bogus, + policy: { maxPriorityFeePerGas: 50_000_000_000n }, + transaction: { + ...baseTransaction, + chainId: 4217, + gas: 626_497n, + maxFeePerGas: 24_000_000_000n, + maxPriorityFeePerGas: 24_000_000_000n, + } as any, + }), + ).not.toThrow() + }) + + test('error: rejects excessive priority fee under a custom policy override', () => { + expect(() => + prepareSponsoredTransaction({ + account: sponsor, + chainId: 4217, + details, + expectedFeeToken: bogus, + policy: { maxPriorityFeePerGas: 20_000_000_000n }, + transaction: { + ...baseTransaction, + chainId: 4217, + gas: 626_497n, + maxFeePerGas: 24_000_000_000n, + maxPriorityFeePerGas: 24_000_000_000n, + } as any, + }), + ).toThrow('maxPriorityFeePerGas exceeds sponsor policy') + }) + + test('ignores undefined policy override values', () => { + expect(() => + prepareSponsoredTransaction({ + account: sponsor, + chainId: 4217, + details, + expectedFeeToken: bogus, + policy: { maxPriorityFeePerGas: undefined } as any, + transaction: { + ...baseTransaction, + chainId: 4217, + gas: 626_497n, + maxFeePerGas: 24_000_000_000n, + maxPriorityFeePerGas: 24_000_000_000n, + } as any, + }), + ).toThrow('maxPriorityFeePerGas exceeds sponsor policy') + }) + test('drops unknown top-level fields from the sponsored transaction', () => { const sponsored = prepareSponsoredTransaction({ account: sponsor, diff --git a/src/tempo/internal/fee-payer.ts b/src/tempo/internal/fee-payer.ts index 636d5e5a..eeef5fc8 100644 --- a/src/tempo/internal/fee-payer.ts +++ b/src/tempo/internal/fee-payer.ts @@ -5,6 +5,7 @@ import { decodeFunctionData } from 'viem' import { Abis, Addresses, Transaction } from 'viem/tempo' import * as TempoAddress_internal from './address.js' +import * as defaults from './defaults.js' import * as Selectors from './selectors.js' /** Returns true if the serialized transaction has a Tempo envelope prefix. */ @@ -26,17 +27,47 @@ export const callScopes = [ [Selectors.approve, Selectors.swapExactAmountOut, Selectors.transferWithMemo], ] +export type Policy = { + maxGas: bigint + maxFeePerGas: bigint + maxPriorityFeePerGas: bigint + maxTotalFee: bigint + maxValidityWindowSeconds: number +} + /** * maxTotalFee must be high enough to cover `transferWithMemo` and * swap transactions at peak gas prices. Bumped from 0.01 ETH in #327. */ -const policy = { +const defaultPolicy: Policy = { maxGas: 2_000_000n, maxFeePerGas: 100_000_000_000n, maxPriorityFeePerGas: 10_000_000_000n, maxTotalFee: 50_000_000_000_000_000n, maxValidityWindowSeconds: 15 * 60, -} as const +} + +const policyByChainId = { + [defaults.chainId.mainnet]: defaultPolicy, + // Moderato regularly needs a higher priority fee than mainnet. + [defaults.chainId.testnet]: { + ...defaultPolicy, + maxPriorityFeePerGas: 50_000_000_000n, + }, +} as const satisfies Record + +function getPolicy(chainId: number, overrides: Partial | undefined): Policy { + const base = policyByChainId[chainId as defaults.ChainId] ?? defaultPolicy + if (!overrides) return base + + return { + maxGas: overrides.maxGas ?? base.maxGas, + maxFeePerGas: overrides.maxFeePerGas ?? base.maxFeePerGas, + maxPriorityFeePerGas: overrides.maxPriorityFeePerGas ?? base.maxPriorityFeePerGas, + maxTotalFee: overrides.maxTotalFee ?? base.maxTotalFee, + maxValidityWindowSeconds: overrides.maxValidityWindowSeconds ?? base.maxValidityWindowSeconds, + } +} /** Validates that a set of transaction calls matches an allowed fee-payer pattern. */ export function validateCalls( @@ -89,6 +120,7 @@ export function prepareSponsoredTransaction(parameters: { details: Record expectedFeeToken?: TempoAddress.Address | undefined now?: Date | undefined + policy?: Partial | undefined transaction: ReturnType<(typeof Transaction)['deserialize']> }) { const { @@ -98,8 +130,10 @@ export function prepareSponsoredTransaction(parameters: { details, expectedFeeToken, now = new Date(), + policy: policyOverrides, transaction, } = parameters + const policy = getPolicy(chainId, policyOverrides) const { accessList, diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index b13a5161..fa78bdcd 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -60,6 +60,7 @@ export function charge( decimals = defaults.decimals, description, externalId, + feePayerPolicy, html, memo, waitForConfirmation = true, @@ -313,6 +314,7 @@ export function charge( chainId: chainId ?? client.chain!.id, details: { amount, currency, recipient }, expectedFeeToken, + policy: feePayerPolicy, transaction: { ...transaction, ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}), @@ -397,6 +399,15 @@ export declare namespace charge { type Parameters = { /** Render payment page when Accept header is text/html (e.g. in browsers) */ html?: boolean | Html.Config | undefined + /** + * Override the fee-sponsor policy used when co-signing Tempo charge + * transactions. Defaults resolve per chain, including a higher + * priority-fee ceiling on Moderato. + * + * If you increase `maxGas` or `maxFeePerGas`, you may also need to raise + * `maxTotalFee` so the combined fee budget remains valid. + */ + feePayerPolicy?: FeePayerPolicy | undefined /** Testnet mode. */ testnet?: boolean | undefined /** @@ -436,6 +447,8 @@ export declare namespace charge { > & { decimals: number } + + type FeePayerPolicy = Partial } type ExpectedTransfer = {