Skip to content

feat: axlUSDC payment rails + network value-accrual fee (FWSS v1.4.0) - #525

Draft
hannahhoward wants to merge 6 commits into
FilOzone:mainfrom
hannahhoward:feat/axlusdc-value-accrual
Draft

feat: axlUSDC payment rails + network value-accrual fee (FWSS v1.4.0)#525
hannahhoward wants to merge 6 commits into
FilOzone:mainfrom
hannahhoward:feat/axlusdc-value-accrual

Conversation

@hannahhoward

@hannahhoward hannahhoward commented Jun 11, 2026

Copy link
Copy Markdown

Summary

Adds bridged USDC (axlUSDC, 6 decimals) as a second payment token for FilecoinWarmStorageService data sets, and introduces a network value-accrual fee (NVAF) on USDC rails: a 200 bps operator commission, locked into each USDC data set's rails at creation, accrued to the FilecoinPay contract itself, sold for native FIL through FilecoinPay's existing recurring Dutch fee auction, and burned to the burn actor (f099). USDFC data sets are unchanged and remain commission-free, preserving a real posted-price discount for the web3-native token while the network captures value when customers choose USDC.

Design

Token selection. A data set's rail token is chosen by the payer-signed paymentToken metadata key ("USDC", "USDFC", or absent = USDFC) — the same extension pattern as withCDN. The choice is covered by the existing CreateDataSet EIP-712 signature, so there are no signature-format changes; SDKs only need to pass one more metadata entry. Per-token pricing resolution lives in the Rails external library to stay inside the 24 KiB limit: the main contract resolves the token choice with an immutable compare and the library keys the price list off that.

Pricing (PriceListUSDC). Storage is priced from a $5.00/TiB/month base (double the USDFC list's $2.50) so providers net $5 per TiB-month on USDC rails; every other amount keeps the USDFC-equivalent base. All SP-bound amounts are grossed up by 1/(1 − 2%) — posted storage price 5.102041 USDC/TiB/month — so the SP nets the base after the commission and the customer bears the NVAF. Because the 0.5% FilecoinPay network fee applies identically to both tokens, it cancels out of the gross-up. The per-dataset fee is set at the 6-decimal per-epoch quantization floor (1 unit/epoch = $0.0864/month), addressing the truncation-to-zero behavior noted in #468; size-proportional rates for very small data sets still truncate toward zero, which the floor backstops.

Commission. Carried as the FilecoinPay per-rail commissionRateBps with serviceFeeRecipient set to the FilecoinPay contract itself, on all three rails (PDP, cache-miss, CDN) of a USDC data set. FilecoinPay fixes a rail's commission at creation, so changes only affect new data sets. The owner can stage the rate via setUSDCCommissionBps (evented); the cap deliberately equals the gross-up (200 bps) so the SP nets at least the list's base amounts for every permitted setting — raising the NVAF beyond 2% requires an upgrade that also revises the posted prices.

Burn. Commission accrued to FilecoinPay's own account lands in the same pot as its 0.5% network fee and sells through the contract's live burnForFees rolling Dutch auction (price decays 3/4 per week, each purchase resets to 4× the clearing price), with the buyer's FIL destroyed via the burn actor. No new contract, no owner, no parameters — the burn path is the one already deployed and running in FilecoinPay. A DEX TWAP swap was considered and rejected: the only live axlUSDC/WFIL pool holds ~$280k with observation cardinality 1, and no Sushi V3 SwapRouter is deployed on Filecoin — the auction needs no on-chain liquidity or oracle.

Upgrade safety

  • Storage layout is append-only (two new slots: dataSetPaymentToken, usdcCommissionBps); make check-layout passes.
  • Pre-upgrade data sets store no token and resolve to USDFC in every fee path (tested by zeroing the slot).
  • A stored rail token matching neither token immutable reverts loudly (UnknownRailToken) instead of silently pricing a 6-decimal token with the 18-decimal list — guards against a future upgrade changing usdcTokenAddress while old-token data sets exist.
  • migrate() backfills the default NVAF only from the uninitialized (zero) state and emits USDCCommissionBpsUpdated.
  • ⚠️ The Rails library ABI changed (createRails signature, new public functions). This PR removes the pinned RAILS_LIB_ADDRESS from deployments.json for both networks (the v1.3.0 deploys pinned a pre-change Rails) so the next deploy relinks against a fresh Rails, and adds a linked-library redeploy step to the upgrade checklist — reusing the old address would brick every core flow after upgrade.
  • USDC support is optional: the zero address disables it (calibration default). Mainnet deploy scripts default to axlUSDC 0xEB466342C4d449BC9f53A865D5Cb90586f405215.

Testing

  • 848 tests pass on top of current main, 26 of them in MultiTokenValueAccrual.t.sol; no behavioral changes for USDFC data sets.
  • New coverage: token selection and rejection paths, exact 6-decimal rate/fee/lockup values, commission skim into FilecoinPay's fee account on streaming settlement, one-time op fees, FilBeam CDN settlements, consent termination, lifecycle close-out with exact reserve return, legacy-data-set fallback (network fee only, no NVAF), the real upgrade path (slot zeroed via vm.store + announcePlannedUpgrade/upgradeToAndCall), fee-auction arming on accrual and a burnForFees buyout with the FIL burned to f099 via the FVM precompile mocks, and funding-requirement reverts.
  • FilecoinWarmStorageService deployed size: 24,193 bytes (383 under the EIP-170 limit). forge fmt clean; ABIs and generated state-view/layout files regenerated.

Known trade-offs

  • Sub-35 GiB data sets round their size-proportional USDC rate toward zero (6-decimal quantization); the dataset-fee floor keeps every active data set paying a non-zero stream.
  • Per-epoch settleRail calls on a floor-rate stream can divert it entirely to the (ceil'd) network fee — grief-only, capped at $0.0864/month per data set, costs the caller gas per epoch.
  • NVAF proceeds co-mingle with the 0.5% network fee: both accrue to FilecoinPay's own account and sell as one lot through burnForFees (lot pricing independent of requested, overpayment burned, no refunds). Both streams end burned either way; per-rail commission amounts remain observable in settlement events.

Test plan

  • Full Foundry suite (848/848, rebased on main)
  • Storage-layout upgrade check (append-only)
  • Contract size check under EIP-170
  • Calibnet rehearsal with a test 6-decimal token (USDC enabled via USDC_TOKEN_ADDRESS)

🤖 Generated with Claude Code

@FilOzzy FilOzzy added this to FOC Jun 11, 2026
@github-project-automation github-project-automation Bot moved this to 📌 Triage in FOC Jun 11, 2026
@hannahhoward
hannahhoward force-pushed the feat/axlusdc-value-accrual branch from dae850d to 839221d Compare June 11, 2026 19:21

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.

The ValueAccrualRouter is entirely unnecessary. FilecoinPay does this automatically. If you want to burn even more than the default 0.5%, you can set the serviceFeeRecipient to address(FilecoinPay), and it will automatically be entered into the rolling double-dutch auction.

@hannahhoward hannahhoward Jul 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good call — removed in 91fe067. USDC rails now set serviceFeeRecipient to the FilecoinPay contract itself, so the commission lands in the same account burnForFees already auctions and burns. The router, its deploy steps, and its tests are gone (net −327 lines).

Comment thread service_contracts/src/lib/Rails.sol Outdated
/// instance, the USDFC list otherwise. Lives here (external library) to keep the per-token
/// price constants out of the main contract's code size. `pl.token` is populated with
/// `token`.
function priceListFor(IERC20 token, IERC20 usdc) public pure returns (PriceList memory pl) {

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.

usdc should not be a parameter to this function

@hannahhoward hannahhoward Jul 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 91fe067 — the main contract resolves the token choice with an immutable compare and passes a bool; priceListFor(bool) is internal to the library now, and the USDC address is no longer threaded through the Rails API.

Comment thread service_contracts/src/lib/Rails.sol Outdated
keyBytes.length == METADATA_KEY_PAYMENT_TOKEN_SIZE
&& keccak256(keyBytes) == METADATA_KEY_PAYMENT_TOKEN_HASH
) {
bytes32 valueHash = keccak256(bytes(metadataValues[i]));

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.

this is considerably more expensive than checking if the value is equal to your length-4 string

@hannahhoward hannahhoward Jul 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 91fe067 — length check plus direct bytes4/bytes5/bytes12 comparisons; the hash constants are gone.

Comment thread service_contracts/src/lib/Rails.sol Outdated
Comment on lines +78 to +81
if (valueHash == PAYMENT_TOKEN_VALUE_USDC_HASH) {
if (address(usdc) == address(0)) {
revert Errors.UnsupportedPaymentToken(metadataValues[i]);
}

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.

We have also thought about supporting USDC. My preference is for having separate contracts per token, with the only difference being the immutable token and the price list. That would be a much smaller change than your PR, and would have a smaller storage footprint per data set.

There's not a good reason for USDC and USDFC data sets to share an account.

@hannahhoward hannahhoward Jul 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

With the router gone (91fe067) the delta here is a lot smaller than what you reviewed — the branch has also since been rebased onto current main. Separate per-token contracts would drop the dispatch and the per-data-set token slot, but each deployment duplicates the proxy/view/provider-approval/FilBeam wiring, SDKs have to target a second service address, and since the EIP-712 domain binds signatures to the verifying contract we'd lose the single-signature-format property — token choice would move from a signed metadata key to a contract-address choice. Keeping the shared contract for now; happy to dig into the split if you still think it pays.

@BigLep BigLep moved this from 📌 Triage to ⌨️ In Progress in FOC Jun 12, 2026
@BigLep BigLep moved this from ⌨️ In Progress to 🐱 Todo in FOC Jun 15, 2026
@BigLep BigLep moved this from 🐱 Todo to ⌨️ In Progress in FOC Jun 15, 2026
@BigLep BigLep moved this from ⌨️ In Progress to 🐱 Todo in FOC Jun 17, 2026
hannahhoward added a commit to hannahhoward/filecoin-services that referenced this pull request Jul 24, 2026
Address wjmelements's review on FilOzone#525:

- Drop the ValueAccrualRouter. FilecoinPay already auctions and burns
  whatever accrues to its own account: setting a rail's
  serviceFeeRecipient to the FilecoinPay contract routes the operator
  commission into accounts[token][payments], the same pot burnForFees
  sells through the rolling Dutch auction. USDC rails now set
  serviceFeeRecipient = FilecoinPay; the router contract, its
  deploy-script steps, its errors, and the constructor wiring are gone.

- Stop threading the USDC token address through the Rails library.
  The main contract resolves token -> price list once (an immutable
  compare) and passes a bool; priceListFor(bool) is now internal to
  the library.

- Compare metadata strings directly instead of hashing. The
  paymentToken key and its USDC/USDFC values are length-checked and
  compared as fixed-size words, replacing the keccak256 comparisons.

819 tests pass (822 before: 5 router-specific tests replaced by 2
covering commission accrual to FilecoinPay and burnForFees buyout).
Storage layout unchanged (25 slots); FWSS runtime size 23,809 bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hannahhoward and others added 6 commits July 24, 2026 12:47
Adds bridged USDC (axlUSDC, 6 decimals) as a second payment token for
FilecoinWarmStorageService data sets, and introduces a network
value-accrual fee (NVAF) on USDC rails: a 200 bps operator commission,
locked into each USDC data set's rails at creation, routed to a new
ValueAccrualRouter contract, sold for native FIL through a recurring
Dutch auction (the same mechanism FilecoinPay uses for its network
fee), and burned to the burn actor (f099). USDFC data sets are
unchanged and remain commission-free.

- Token selection rides the payer-signed `paymentToken` metadata key,
  so the existing CreateDataSet EIP-712 signature covers the choice;
  no signature-format changes.
- PriceListUSDC grosses SP-bound amounts up by 1/(1 - 2%) so the SP
  nets the USDFC-equivalent after the commission; the per-dataset fee
  sits at the 6-decimal per-epoch quantization floor ($0.0864/month).
- The commission cap equals the gross-up (200 bps) so the SP-parity
  guarantee holds for every permitted owner setting.
- Storage layout is append-only (two new slots); pre-upgrade data sets
  resolve to USDFC in all fee paths, and a stored token matching
  neither token immutable reverts loudly (UnknownRailToken).
- Deploy scripts deploy the router and pass the new constructor args;
  mainnet defaults USDC to axlUSDC, calibration defaults to disabled.
  Removes calibnet's pinned RAILS_LIB_ADDRESS (Rails ABI changed) and
  adds a linked-library redeploy step to the upgrade checklist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Providers net USD 5 per TiB-month on USDC rails (double the USDFC
list's USD 2.50 base); the posted price carries the same 1/(1 - 2%)
NVAF gross-up, giving 5.102041 USDC/TiB/month. All other USDC amounts
keep the USDFC-equivalent base.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v1.3.0 mainnet deploy (FilOzone#523) pinned a Rails library built before
this change. Rails' ABI changes here (createRails signature, new
public functions), so reusing the pinned address would link the new
implementation against selectors the deployed library does not have.
Removing the pin makes the next deploy relink against a fresh Rails,
matching the calibnet unpin and the new upgrade-checklist step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Reformat with forge 1.3.5 (CI pin); newer local forge formatted a few
  constructs differently and would fail the CI fmt check.
- Drop unused re-exported constants from the PriceListUSDFC import
  (EPOCHS_PER_DAY moves consumers to PriceList.sol, GIB/MIB unused).
- Use checked SafeERC20 transfers for the 6-decimal mock in tests.

forge fmt --check and forge lint are clean under v1.3.5; 823 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address wjmelements's review on FilOzone#525:

- Drop the ValueAccrualRouter. FilecoinPay already auctions and burns
  whatever accrues to its own account: setting a rail's
  serviceFeeRecipient to the FilecoinPay contract routes the operator
  commission into accounts[token][payments], the same pot burnForFees
  sells through the rolling Dutch auction. USDC rails now set
  serviceFeeRecipient = FilecoinPay; the router contract, its
  deploy-script steps, its errors, and the constructor wiring are gone.

- Stop threading the USDC token address through the Rails library.
  The main contract resolves token -> price list once (an immutable
  compare) and passes a bool; priceListFor(bool) is now internal to
  the library.

- Compare metadata strings directly instead of hashing. The
  paymentToken key and its USDC/USDFC values are length-checked and
  compared as fixed-size words, replacing the keccak256 comparisons.

819 tests pass (822 before: 5 router-specific tests replaced by 2
covering commission accrual to FilecoinPay and burnForFees buyout).
Storage layout unchanged (25 slots); FWSS runtime size 23,809 bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two build fixes from rebasing onto main: abandonRails (underfunded-payer
handling, FilOzone#520) uses DEFAULT_LOCKUP_PERIOD, which this branch moved from
PriceListUSDFC to PriceList — import it from its new home; and the new
announce-upgrade-only-owner test constructs FWSS, which now takes the
optional USDC token parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hannahhoward
hannahhoward force-pushed the feat/axlusdc-value-accrual branch from 95e259b to dc02b43 Compare July 24, 2026 19:55
@hannahhoward

Copy link
Copy Markdown
Author

@wjmelements @BigLep sorry.... I never saw this got reviewed. I've responded to changes and rebased on latest. At the same time, I never meant to be primary dev on this especially since a lot of it was AI generated. I can keep working if you like -- let me know -- otherwise I'll tree this as a WIP contribution that you guys are picking up. I also believe @rvagg and @jennijuju had some significant design changes they wanted to make.

@BigLep

BigLep commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

I never meant to be primary dev on this especially since a lot of it was AI generated.

Yeah, understood - all good. My name showing up in the audit log was just an agent I run locally using my credentials for organizing things. I'm not aware of the plan around this specific area or timeline. I don't know enough to have expectations that you'd driving this or doing development work here. I assume here would be more discussion and agreement before this get actively engaged in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🐱 Todo

Development

Successfully merging this pull request may close these issues.

4 participants