Fix Firo Open CryptoPay minimum fee rejecting Spark payments - #4305
Conversation
The Firo customer-facing Open CryptoPay minimum was taken from DFX's own payout fee rate (estimateSmartFee times the CPFP/default margin, ~2.068 sat/vB). Firo Spark transactions carry a protocol-fixed fee at the network relay minimum (~1 sat/vB) that the user cannot raise, so valid Spark payments were rejected. Use Firo's relay floor as the customer minimum (configurable via FIRO_MIN_FEE_RATE, default 1 sat/vB); the payout margin stays on DFX's own payout path. Bitcoin keeps its margin-based minimum because its fees are user-adjustable.
|
Two questions on this before landing:
Possible middle ground: use |
For consistency with the Firo fix, the customer-facing Open CryptoPay minimum for Bitcoin no longer derives from DFX's payout send rate (which carries a CPFP/default margin meant only for DFX's own outbound spends). Use the network's recommended next-block rate, floored at the relay minimum so the advertised minimum stays relayable. The payout margin remains on DFX's own payout/payin/dex paths, unchanged.
Summary of changesFiro (commit 1): The Open CryptoPay customer-facing minimum for Firo was derived from DFX's own payout send rate ( Bitcoin (commit 2): For consistency, Bitcoin's customer-facing minimum no longer derives from the payout send rate either. It now uses the recommended (next-block) rate, floored at the relay minimum so the advertised minimum stays relayable. The payout margin remains on DFX's own payout/payin/dex paths, unchanged. Both UTXO chains now follow the same principle — the customer minimum is the network's own minimum for an inbound payment to confirm, without DFX's outbound CPFP margin. Values differ per chain: Bitcoin adapts to congestion (recommended rate + relay-floor clamp); Firo uses a fixed relay floor because Spark fees are protocol-fixed and Firo does not congest. Reviewed across conformity, logic, and security/failure-mode dimensions (two rounds — the second after adding the Bitcoin change). No blocking findings. Verified: strict relaxation, so no previously-successful payment can now fail; sub-relay-min transactions are still rejected by the node at broadcast; no other blockchain or DFX payout path is affected. Tests: added Out of scope (pre-existing, unchanged): Firo credits at |
…d floor The current OCP Firo deposit address is transparent, so a Stack Wallet payment is a Spark-spend to it whose fee sits at the relay floor and cannot be raised. Mirror the Bitcoin approach: use Firo's own estimatesmartfee(1) without the payout CPFP margin, floored at the relay minimum, instead of a hardcoded FIRO_MIN_FEE_RATE constant. On a quiet Firo node estimatesmartfee returns null (the normal state) and degrades to the relay floor; a genuine node/RPC error propagates so Firo fails closed (drops out of the fee cache) like Bitcoin rather than being advertised at 1. Removes the now-unused FIRO_MIN_FEE_RATE config and adds PayoutFiroService tests for the estimate/null/error branches.
Problem
During Stack Wallet OCP testing on Firo (FIRO mainnet, dfx.shop test product), sending was blocked with "Open CryptoPay requires at least 2.068 sat/vB fee". That advertised
transferAmounts[].minFeewas derived from DFX's own payout fee rate —estimateSmartFee(1)times the CPFP/default margin. That CPFP margin exists only for DFX's outbound spends (where DFX may child-pays-for-parent its own batching); a paying customer cannot CPFP an inbound payment, so charging the margin on the customer-facing minimum was wrong on principle.The current OCP Firo deposit address (
PAYMENT_FIRO_ADDRESS) is transparent. A Stack Wallet payment is therefore a Spark-spend to a transparent address, whose fee sits at Firo's relay floor and cannot be raised by the user — so a margin-multiplied minimum could never be met.Fix
Decouple the customer-facing minimum from the payout margin on both UTXO chains, using each network's own next-block rate (
estimateSmartFee(1), no CPFP margin), floored at the relay minimum (MIN_FEE_RATE_SAT_VB= 1 sat/vB) so the advertised minimum is always relayable.max(getRecommendedFeeRate(), relayFloor)— adapts to congestion, floored.The CPFP/default margin stays on DFX's own payout path (
getCurrentFeeRate→getSendFeeRate, used bysendUtxoToMany), unchanged.Firo fail-closed vs quiet-node
PayoutFiroService.getRecommendedFeeRate()returnsestimateSmartFee(1) ?? relayFloor:Acceptance itself is independently fenced by the node's real relay floor (
testMempoolAcceptfor the hex path,getRawTxfor the Firo tx-id path), so the advertised minimum cannot admit an unrelayable tx.Changes
payment-link-fee.service.ts: Bitcoin + Firo minimum =max(recommended rate, relay floor), no payout marginpayout-bitcoin.service.ts/payout-firo.service.ts: newgetRecommendedFeeRate()(Firo null-safe → relay floor, node error fails closed)bitcoin-based-fee.service.ts: export the sharedMIN_FEE_RATE_SAT_VBrelay floorpayment-link-fee.service.spec.ts(both chains, relay-floor clamp) andpayout-firo.service.spec.ts(estimate / null→floor / error-propagation branches)PR completeness
FIRO_MIN_FEE_RATEenv var was dropped; the minimum is now derived from the nodetransferAmounts[].minFeeDTO shape unchanged)Test plan
format/lint/type-check: cleannpm test payment-link-fee.service payout-firo.service: 22 passingOut of scope
Firo credits the merchant at
TX_MEMPOOL(before confirmation) with no reversal path — pre-existing, governed byminCompletionStatus, unaffected by this change. A dedicated Spark (sm1…) deposit address — whose protocol-capped fee would want a relay-floor cap rather than a congestion-adaptive minimum — is a separate, larger piece of work (new shielded inbound rail) and is not part of this PR.