Skip to content

[PERA-4490] PQ-017: external signing PQ fee override on dApp transaction groups (WC/deeplinks)#1004

Merged
fmsouza merged 16 commits into
mainfrom
fmsouza/pera-4490
Jul 24, 2026
Merged

[PERA-4490] PQ-017: external signing PQ fee override on dApp transaction groups (WC/deeplinks)#1004
fmsouza merged 16 commits into
mainfrom
fmsouza/pera-4490

Conversation

@fmsouza

@fmsouza fmsouza commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Description

Implements PQ-017: when an external (WalletConnect / webview) transaction group will be signed by a quantum account and the dApp-set fee is below the post-quantum minimum, the wallet raises the fee before the user reviews and signs, re-assigns the group ID across the full group, and returns the modified signed transactions to the dApp.

  • applyQuantumFeeOverride (packages/signing) — pure core: raises quantum-signed txn fees to max(algod suggestedMinFee, fee_min_txn_fee) × fee_pq_multiplier (defaults 1000 × 3 = 3,000 µAlgo), validates the incoming group as received, then re-derives grp over each affected partition in full — including txns Pera doesn't sign. Fees are never lowered; groups with no quantum signer are returned byte-identical (same references).
  • Enqueue wiring — the override runs in useEnqueueArc0001SignRequest (the real runtime WC/webview convergence point — the ticket's createWalletConnectSource.ts is a runtime-dead legacy layer, its pass-through regression comment now points here), so the review sheet shows final fees. Non-quantum requests take a fast path with no suggested-params fetch and zero behavior change. feeAdjustments is recorded on the request; invalid incoming groups reject to the dApp without enqueueing.
  • Callback transport quantum gate openedassertNoQuantumSignedTransactions removed; pqsig carriers now flow back to the dApp via the carrier-aware encoder (nulls at unsigned slots per ARC-0001). All widened approve consumers audited; swap flows fail loud on unexpected quantum carriers instead of silently dropping them.
  • UI (reuses PQ-011) — FeeDisplay shows an "Adjusted" label; QuantumFeeExplainer renders original → adjusted amounts (useQuantumFeeAdjustment hook, new transactions.quantum_fee.adjusted_* keys).
  • Rejection path — delivery failure of an adjusted response surfaces QuantumFeeDeliveryError → i18n toast noting the dApp may not support Quantum account fees (walletconnect.request.quantum_fee_delivery_failed), through the existing WC error handling.
  • Deeplinks — keyreg deeplink builds floor the fee the same way (PQ-007 pattern); WC-uri deeplinks converge on the same enqueue path.
  • Pooled-fee limitation (documented) — each quantum txn is raised to its own minimum; a co-member's fee surplus does not exempt it.

Related Issues

Checklist

  • Have you tested your changes locally?
  • Have you reviewed the code for any potential issues?
  • Have you documented any necessary changes in the project's documentation?
  • Have you added any necessary tests for your changes?
  • Have you updated any relevant dependencies?

Additional Notes

  • Tests: 15-case unit matrix for the override core (fee matrix, group-ID recompute, integrity pass/fail as received, mixed groups, rekey both directions, congestion guard), enqueue/delivery specs, keyreg deeplink floor specs, and a full end-to-end integration test (wc-sign-quantum-fee.test.tsx) driving the real WC connector → review sheet → real Falcon signing → callback, including the byte-identical (rawTransactionsMatch) non-quantum regression.
  • Verification: pnpm pre-push fully green; full pnpm test matches the pre-existing local integration baseline exactly (no regressions).
  • QA deliverable: docs/QUANTUM_PQ017_DAPP_COMPATIBILITY_TEST_PLAN.md — behavior contract, dApp test matrix, per-dApp procedure, limitations. dApps that hash-check returned bytes will reject modified groups by design; that is a per-dApp compatibility gap to record, not a wallet defect.

🤖 Generated with Claude Code

fmsouza added 11 commits July 21, 2026 11:27
…t [PERA-4490]

Removes assertNoQuantumSignedTransactions and its call in buildSourceMetadata's
transaction-approve wrapper — the callback transport (WalletConnect / webview /
deeplink / local-callback) now forwards a QuantumSignedTransaction pqsig carrier
to the requester unchanged instead of throwing, since encodeSignedTransaction is
already carrier-aware. Widens TransactionSignRequest.approve to
Nullable<PeraSignedTxnResult>[] to match the real runtime contract, and updates
every consumer surfaced by the type change (useSignAndSubmitGroup,
requestRekeySignatures, useFeeDelegation, and the swap module's
requestSwapSignatures, which defensively excludes quantum carriers since swaps
don't route quantum accounts today).
requestSwapSignatures' approve callback silently filtered out any
QuantumSignedTransaction carrier alongside the defensive null-filter.
Quantum accounts are only kept out of swap by a feature flag today,
not a structural guard, so a silent drop would vanish signed slots
and corrupt the group into an opaque downstream crash. Throw instead,
mirroring the fail-loud contract of assertNoQuantumSignedTransactions
(the callback-transport gate PQ-017 removed from the signing machine).
…t [PERA-4490]

Build the QuantumFeeDeliveryError message with a template literal against
QUANTUM_FEE_DELIVERY_MESSAGE_MARKER instead of a hardcoded phrase, so a future
rewording can't silently break the marker-based detection used for
WalletConnect's connectionError rewrap. Adds a test asserting the rejected
error's message contains the marker.
…ck [PERA-4490]

useDeepLink.test.ts re-mocks wallet-core-blockchain/-signing for the keyreg
builder assertions; without useMinimumFeeConfig/getSuggestedParams/
resolveMinFeeForSender the hook throws when rendered after task 6's fee-floor
change.
@fmsouza
fmsouza requested a review from a team as a code owner July 21, 2026 13:21
const { addSignRequest } = useSigningRequest()
const allAccounts = useAllAccounts()
const showError = useDeeplinkErrorHandler()
const { minTxnFee, pqMultiplier } = useMinimumFeeConfig()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I've mentioned this before but just in case not I think we're going to want this to be something more like useMinimumFeeCalculator which returns an "assignFeeToGroup" and it computes the actual fee requirements for the whole group. In this first implementation it can just look for pq accounts and change the fee, but in a future implementation it's going to have to do a bunch of conditional stuff based on different factors (or, probably more likely, just send the tx group to the simulate endpoint).

I think it's worth preparing an interface now that makes sense for that future.

Comment on lines +153 to +183
const dAppFee = data.fee ? BigInt(data.fee) : undefined

// Mirrors PQ-007's useTransactionSendFlow.buildNormalTxs: the
// dApp-set (or default) fee is honored verbatim unless the
// sender is PQ-signed, in which case `resolvedMin` exceeds
// algod's suggested minimum and the fee is floored up to it
// — never lowered — so a quantum sender's keyreg isn't
// rejected as underfunded.
const suggestedParams = await withTimeout(
'keyregSuggestedParams',
KEYREG_BUILD_TIMEOUT_MS,
algorandClient.getSuggestedParams(),
)
const suggestedMinFee = BigInt(suggestedParams.minFee)
const resolvedMin = resolveMinFeeForSender({
senderAddress: data.senderAddress,
accounts: allAccounts,
suggestedMinFee,
configMinTxnFee: minTxnFee,
pqMultiplier,
})
const staticFee =
resolvedMin > suggestedMinFee
? microAlgo(
dAppFee !== undefined && dAppFee > resolvedMin
? dAppFee
: resolvedMin,
)
: dAppFee !== undefined
? microAlgo(dAppFee)
: undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you change the above thing to a fee calculator, this can all pretty much go back to the previous version I think?

Comment on lines +52 to +54
export const useQuantumFeeAdjustment = (
transaction?: PeraDisplayableTransaction,
): UseQuantumFeeAdjustmentResult => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as before, I think we can have a general function that just sets the right fee for any tx group and we just always call that.

Comment on lines +667 to +671
expect(outcome).toEqual({
kind: 'error',
phase: 'signing',
message: 'Quantum accounts are not supported in swap flows yet',
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not support PQ in swap?

signableIndices: number[],
sourceMetadata: TransactionSignRequest['sourceMetadata'],
): Promise<PeraSignedTransaction[]> =>
): Promise<PeraSignedTxnResult[]> =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this type change. Shoudn't PeraSignedTransaction just change shape to accommodate PQ if necessary?

I think making all these pervasive intermediate changes is making the whole train way more complicated and brittle. Totally cool to stub out functionality for a future PR but I think adding new flows and changing signatures to temporary structures feels like it's making things more complicated rather than less so?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't need changes here at all

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't need to touch this

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is really legacy, can we just delete it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the adjustFeeIfNecessary function could just return a list of adjusted txns and an object describing the fee adjustments and then you don't need separate concepts.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes not needed

…culator [PERA-4490]

Review follow-ups (wjbeau):
- useMinimumFeeCalculator().assignFeeToGroup(group) is now the single seam
  that assigns required minimum fees to any tx group (quantum precheck +
  suggested-params fetch live inside; free no-op for non-quantum groups).
  PQ-022's simulate()-based fees swap in behind this interface.
- Core renamed applyQuantumFeeOverride -> assignMinimumFeesToGroup; records
  are scheme-agnostic FeeAdjustment { index, originalFee, adjustedFee,
  reason } ('quantum-minimum' is just today's only rule).
- useEnqueueArc0001SignRequest collapses its inline quantum branching into
  one assignFeeToGroup call; useKeyregDeeplink builds with the dApp fee
  verbatim again (previous shape) and floors post-build via the calculator —
  non-quantum keyreg deeplinks no longer fetch suggested params and their
  fees are never floored (restores zero-behavior-change for non-quantum).
- QuantumFeeDeliveryError -> FeeAdjustmentDeliveryError (marker
  'fee-adjusted'); UI hook useQuantumFeeAdjustment -> useFeeAdjustment.
- Deleted the runtime-dead legacy createWalletConnectSource DataSource +
  spec (live path is the actor machine + enqueue hook).

@wjbeau wjbeau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks much better, just one small comment in there.

let suggestedMinFee = 0n
try {
suggestedMinFee = BigInt(
(await algokit.getSuggestedParams()).minFee,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might extract this out of the useCallback into a useSuggestedParams or something (I think we might have that already). The reason is that if you do this it will cache it indefinitely (since accounts, algokit, minTxnFee and pqMultiplier may likely not change), but the suggested params can change if, for example, the network becomes congested. I think our existing hook caches it for like 15s or something.

fmsouza added 4 commits July 24, 2026 11:29
…ms cache; centralize signed-result narrowing [PERA-4490]

Review follow-ups (wjbeau, approval round):
- useMinimumFeeCalculator now fetches params via useFetchSuggestedParameters,
  a fetch-through-cache companion to useSuggestedParametersQuery (same query
  key, 10s staleness, networkMode 'always') — congestion-driven minFee
  changes propagate on the app-wide contract while staying lazy: non-quantum
  groups still fetch nothing.
- compactSignedResults (blockchain models) replaces the four hand-rolled
  null-filter sites (useSignAndSubmitGroup, useFeeDelegation,
  requestRekeySignatures, swapExecutionHelpers) — one narrowing seam for
  PQ-023 to update when the carrier collapses into the signed model.
…uggested-min-fee source [PERA-4490]

Follows the shape wjbeau suggested on the calculator: the min-fee
acquisition is a named, reusable function outside the useCallback.
useFetchSuggestedMinFee (blockchain) wraps the fetch-through-cache params
fetcher and returns the network minFee as bigint, with an optional
fallback for never-block callers.

Consumers migrated off private BigInt((await algokit.getSuggestedParams()).minFee)
acquisitions onto the shared query-cache entry:
- useMinimumFeeCalculator (fallback: 0n — config base applies offline)
- useSubmitRekeyMutation (submit-side fee now reads the same cache entry
  as the useRekeyTransactionFeeQuery display)
- useTransactionSendFlow buildNormalTxs + buildExpressTxs

Render-time consumers (useMinFeeForSender, useRekeyTransactionFeeQuery)
stay on the eager useSuggestedParametersQuery — same key, same entry.
…n/removeScripts advisories

Unblocks the Audit CI gate. Two fresh high advisories on fast-uri
(GHSA-4c8g-83qw-93j6, GHSA-v2hh-gcrm-f6hx — host confusion via failed IDN
canonicalization / literal backslash authority delimiter) flag >=4.0.0.
We were on 4.0.0 because the previous override target (">=3.1.2") was
unbounded and let the resolver escape ajv's declared ^3.0.1 range into the
4.x line. Re-pin exact INSIDE the range: 3.1.4, upstream's 3.x backport of
both fixes (same advisory ids on the releases). 3.1.4 is 5 days old, so
fast-uri gets a TEMPORARY minimumReleaseAgeExclude entry (maintainer
continuity verified: same 11 maintainers, no install scripts) — remove
after 2026-07-26 when it clears the 7-day window.

Also pins svgo 3.3.4 (GHSA-2p49-hgcm-8545, dev-only via
react-native-svg-transformer > @svgr/plugin-svgo; 13 days old, no exclude
needed) — pre-existing on main, fixed here since the lockfile is already
being touched.

Both CI audit commands verified clean locally; lockfile diff is the two
pins only. Pre-existing on main (this PR doesn't otherwise touch deps).
…confusion/removeScripts advisories"

This reverts commit 333f393.
@fmsouza
fmsouza merged commit a1794d6 into main Jul 24, 2026
9 checks passed
@fmsouza
fmsouza deleted the fmsouza/pera-4490 branch July 24, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants