Skip to content

feat(payments): EventDriven per-job billing in the service's settlement asset (native or ERC20)#209

Merged
drewstone merged 2 commits into
mainfrom
feat/eventdriven-erc20-settlement
Jul 9, 2026
Merged

feat(payments): EventDriven per-job billing in the service's settlement asset (native or ERC20)#209
drewstone merged 2 commits into
mainfrom
feat/eventdriven-erc20-settlement

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

Summary

Generalizes EventDriven per-job billing to settle in the service's developer-declared settlement asset — native OR a manager-allowed ERC20 (e.g. Tempo's PathUSD) — instead of a native-msg.value-only hardcode. This unblocks paid jobs on chains that forbid native value transfers (Tempo bans them at the VM level), while keeping native byte-identical on Tangle L1s.

Why

PaymentLib.collectPayment already handles both native and hardened ERC20 (fee-on-transfer rejection, msgValue==0 enforced); requestService/escrow already carry a paymentToken. Only the EventDriven per-job path hardcoded address(0), and one rule forbade a token — so pay-per-job services could not exist on a value-banning chain. This is a consistency fix reusing audited primitives, not new payment machinery.

Design (the key safety decision)

The settlement asset is chosen by the developer at the blueprint level (_blueprintSettlementAsset[blueprintId], owner-gated setBlueprintSettlementAsset, allow-list-validated), and pinned to the service at activation — never chosen by the customer. This binds the asset and the per-job rate to the same party in the same units, so a decimal/asset mismatch is structurally impossible. Rate is denominated in the settlement asset's smallest unit (documented on the setter/view).

Security process — two adversarial-review rounds

  • Round 1 found a real HIGH: v1 pinned the asset from the customer's request while the rate is per-blueprint → a customer could request native against a 6-dec PathUSD rate and settle every job at ~1e-12× the intended price (10^12× underpayment), with the base BSM unable to deny native.
  • Fix: moved asset choice to the developer/blueprint, pinned at activation, re-closed requestService for EventDriven (customer token must be address(0)), and closed a second live path — quote-created services (createServiceFromQuotes) also never pinned the asset.
  • Round 2: merge_safe: true, zero confirmed issues across collect-vs-distribute asset consistency, storage-append safety, fail-closed asset authorization (4 layers), reentrancy/CEI (global OZ guard holds across the new ERC20 callbacks; pull-pattern payouts), and native back-compat.

Verification

  • New suite EventDrivenErc20Settlement.t.sol 26/26, incl. the explicit exploit test test_Exploit_SixDecimalRateCannotSettleInNative (no path lets a service settle in an asset other than its blueprint's declared one), customer-cannot-choose, ERC20-settles-via-transferFrom, native back-compat, pin-at-activation immutability, quote-path pin, setter auth/validation.
  • Regression: tangle 383/383, audit 232/232, staking 266/266, security (incl. StorageLayoutSnapshot) 45/45, blueprints 97/97, scenario 10/10, Integration/E2E/subscription all green.
  • Storage append-only: _serviceEventDrivenAsset@90, _blueprintSettlementAsset@91, __gap 27→25, tail pins (88/89) byte-identical to main — StorageLayoutSnapshotTest 3/3.
  • FacetSize/Tempo-cap 3/3 under the optimized profile (touched facets stay < 21,500 B).
  • Rust bindings regenerated (getBlueprintSettlementAsset/setBlueprintSettlementAsset/getServicePaymentAsset + event).

Rollback / compat

Default asset is address(0) (native) — a blueprint that never sets an asset behaves exactly as before. Revertable by dropping the branch.

Known minor (non-blocking, flagged for the reviewer)

  • Dead code: TangleJobsRFQFacet._distributeRFQJobPayment still passes address(0) — proven unreachable (no result selector on that facet); delete for hygiene.
  • LOW liveness (not security, fail-closed): setBlueprintSettlementAsset validates the asset with blueprintId context but collection re-checks with serviceId context; a manager returning different answers per context could brick billing (reverts, no theft). Optional: validate with serviceId context at activation for consistency.
  • Decimals are the developer's responsibility (rate denominated in the settlement asset) — documented, and now un-exploitable since the same party sets asset + rate.

drewstone added 2 commits July 8, 2026 20:00
…nt asset (native or ERC20)

Generalize EventDriven job payment so each service settles per-job in the
token the customer selected at request time — native (address(0)) OR a
manager-allowed ERC20 (e.g. Tempo PathUSD) — instead of a native-only
hardcode. Reuses the already-token-aware PaymentLib.collectPayment and the
token-parameterized distribution core; subscriptions already used ERC20.

- ServicesRequests: relax EventDriven to allow naming a settlement token
  (drop the native-only revert; keep the no-upfront-amount rule), and
  validate a NON-native EventDriven token via the manager allow-list at
  request time (fail-closed: disallowed -> TokenNotAllowed). Native needs
  no gate (unchanged).
- Base storage (TangleStorage): append mapping(uint64=>address)
  _serviceEventDrivenAsset after _managerHookGasLimit; shrink __gap 27->26.
  Additive only — StorageLayoutSnapshotTest tail pins (slots 88/89) hold.
- TangleServicesFacet: pin the settlement asset at activation from the
  approved request's paymentToken.
- JobsSubmission / JobsRFQ / Jobs(+Aggregation) facets: collect and
  distribute each job in _serviceEventDrivenAsset[serviceId]; re-gate the
  asset against the manager allow-list at collection time (fail-closed).
- Add getServicePaymentAsset(uint64) view (router-served) + interface
  decl; document that the per-job rate is denominated in the settlement
  asset's smallest unit (setJobEventRates / getJobEventRate NatSpec).
- Tests: new EventDrivenErc20Settlement suite (16) covering ERC20 happy
  path, native back-compat, fail-closed (disallowed token, native-value
  on ERC20 service, zero-value on native service, post-activation
  de-list), RFQ ERC20, 6-decimal token, and a fuzz; update the obsolete
  native-only Payments test to the new fail-closed contract.
- Regenerate Rust bindings for the new view.
…eloper), not the customer request

Closes a HIGH-severity 10^12x under-payment: the per-job rate is per-blueprint
(developer-set, `_jobEventRates`/`eventRate`) but the settlement asset was pinned
per-service from the CUSTOMER's request `paymentToken`. A developer authoring a
6-decimal rate (e.g. 5e6 = 5.00 PathUSD) could be driven to settle in native by a
customer requesting `address(0)`, so every job settled at 5e6 wei (~$0) while
consuming real compute.

Root fix: the settlement asset is now declared by the blueprint OWNER at the
blueprint level and pinned to the service at activation from the blueprint — the
same party sets asset and rate, so their units always match and there is no code
path to a service whose settlement asset differs from its blueprint's declared asset.

- TangleStorage: append `_blueprintSettlementAsset[blueprintId]` after
  `_serviceEventDrivenAsset` (slot 91); shrink `__gap` 26->25 (slot 92). No slot
  reorder — StorageLayoutSnapshotTest tail pins (88/89) unchanged.
- BlueprintsManage: owner-gated `setBlueprintSettlementAsset` (mirrors
  `setJobEventRates` auth) validating non-native assets via the manager allow-list
  (native `address(0)` skips the gate, mirroring the request-time native path);
  `getBlueprintSettlementAsset` view. Rate is denominated in this asset's smallest unit.
- TangleServicesFacet `_handleInitialPayments`: pin the service from
  `_blueprintSettlementAsset[blueprintId]`, never the request's `paymentToken`.
- QuotesCreate `_activateQuoteService`: pin the same blueprint asset — the RFQ
  create path is a second activation path and must not leave an ERC20 blueprint's
  service at the native default.
- ServicesRequests `_validatePricingPaymentConsistency`: an EventDriven request must
  pass native `address(0)` (revert otherwise) and zero `paymentAmount`. The customer
  cannot choose the asset; simplify `_validateRequestPaymentAsset` accordingly.
- Collection/distribution (JobsSubmission/JobsRFQ/facets) unchanged: still read
  `_serviceEventDrivenAsset[serviceId]`, now guaranteed equal to the blueprint asset.
- Regenerate bindings for the new setter/view/event.

Tests: EventDrivenErc20Settlement adds the explicit exploit test (a 6-dec rate cannot
settle in native), customer-cannot-choose-asset, pin-at-activation, quote-path pin,
setter auth/validation, plus native back-compat. 26/26 in that suite; regression green
(tangle 383, audit 232, staking 266, security 45, blueprints 97, FacetSize/Tempo-cap 3
under the optimized profile).

@tangletools tangletools 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.

✅ Auto-approved drewstone PR — e819cdca

This PR was opened by the trusted drewstone account.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.

tangletools · auto-approval · reason: drewstone_author · 2026-07-09T04:12:28Z

@tangletools tangletools 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.

🟡 Value Audit — sound-with-nits

Verdict sound-with-nits
Concerns 2 (1 low, 1 weak-concern)
Heuristic 0.2s
Duplication 0.0s
Interrogation 227.6s (2 bridge agents)
Total 227.8s

💰 Value — sound

Generalizes EventDriven per-job billing to settle in the blueprint developer's chosen asset (native or manager-allowed ERC20), pinning it at service activation so a 6-decimal rate can never be under-paid in native; it reuses existing token-agnostic payment primitives and closes the only gap that sti

  • What it does: Adds blueprint-level settlement-asset declaration (setBlueprintSettlementAsset in src/core/BlueprintsManage.sol:324) and pins it per-service at activation (src/facets/tangle/TangleServicesFacet.sol:199 and src/core/QuotesCreate.sol:245). The per-job collection path (src/core/JobsSubmission.sol:181) and RFQ job path (src/core/JobsRFQ.sol:97) then read the pinned `_serviceEventDrivenAsse
  • Goals it achieves: Enables paid EventDriven jobs on chains that forbid native value transfers (e.g., Tempo) while keeping native behavior byte-identical on Tangle L1; brings EventDriven pricing in line with Subscription/PayOnce, which already carry a paymentToken and use PaymentLib/distributePayment; prevents a decimal/asset mismatch exploit where a 6-decimal ERC20 rate settles in native for a ~1e12x underpaym
  • Assessment: Good. The architecture binds the asset and rate to the same party (the blueprint developer), pins the asset at activation so live services are not re-priced, and re-checks the manager allow-list at job time (fail-closed). It touches every path that can create or bill an EventDriven service: request/approve, quote-create, regular job submission, RFQ jobs, and aggregated-result submission. It reuses
  • Better / existing approach: none — this is the right approach. I searched for existing generic settlement-asset storage (grep -R "paymentToken\|collectPayment\|distributePayment" src/) and confirmed PaymentLib.collectPayment already handles both native and ERC20 with fee-on-transfer rejection, distributePayment (src/facets/tangle/TanglePaymentsDistributionFacet.sol:34) already takes a token address, and `_isPaymentAs
  • Model: opencode/kimi-for-coding/k2p7
  • Bridge attempts: 1

🎯 Usefulness — sound-with-nits

A clean, well-tested consistency fix that generalizes EventDriven per-job billing to a developer-declared settlement asset (native OR ERC20), correctly reusing audited primitives and wiring into every reachable collection/distribution/activation path; only blemish is one stale unreachable override l

  • Integration: Fully wired and reachable through all standard flows. The new storage (_serviceEventDrivenAsset / _blueprintSettlementAsset) is pinned at activation on BOTH creation paths — request/approve (TangleServicesFacet.sol:201) and quote-create (QuotesCreate.sol:240-243) — read by both collection sites (JobsSubmission.sol:178-193, JobsRFQ.sol:97-104), and threaded through all FOUR live distribution overri
  • Fit with existing patterns: Excellent fit — does not compete with anything. It reuses the EXISTING PaymentLib.collectPayment (src/libraries/PaymentLib.sol:208-246), which already handled native AND hardened ERC20 (msgValue==0 enforced, fee-on-transfer/rebasing tokens rejected at ingress via balance-delta), and the EXISTING distributePayment(token,...) self-call (TanglePaymentsDistributionFacet.sol:34). Subscription and PayOn
  • Real-world viability: Holds up well off the happy path. An 820-line test (test/tangle/EventDrivenErc20Settlement.t.sol) covers: ERC20 happy collection+distribution, native back-compat, no-approval revert, native-value-on-ERC20 revert, manager de-list at settle time (fail-closed), the customer-can't-choose-asset exploit, quote-created services pinning the asset, RFQ quote redemption in ERC20, and live-service-not-repric
  • Model: opencode/zai-coding-plan/glm-5.2
  • Bridge attempts: 1

🔎 Heuristic Signals

🟡 Cruft: magic number added test/tangle/EventDrivenErc20Settlement.t.sol

  •    assertEq(tangle.getServicePaymentAsset(9999), address(0));
    

🎯 Usefulness Audit

🟡 Stale address(0) in TangleJobsRFQFacet._distributeRFQJobPayment is unreachable but now inconsistent with its live siblings [integration] ``

TangleJobsRFQFacet.sol:36 still calls distributePayment(..., address(0), ...) while the two live overrides (TangleJobsFacet.sol:52, TangleJobsAggregationFacet.sol:48) were updated to use _serviceEventDrivenAsset[serviceId]. I traced the dispatch: distribution fires only from _maybeFinalizeJob (JobsSubmission.sol:342) and the aggregation equivalent (JobsAggregation.sol:145), both reached via result-submission selectors owned by the Jobs/Aggregation facets — so the RFQ facet's override can NEVER e


What this audit checks

It judges the change on its merits — not whether it was tasked out in an issue. Unticketed, fast-moving work is fine; the question is whether the change is good and whether a better or existing approach should be used instead.

Pass What it asks
Heuristic Vague title? Whitespace-only or cruft-bearing diff? (content signals only)
Duplication Do added function/class names already exist elsewhere in the repo?
Value Audit What does it do? What goal does it achieve? Is it good? Better architecture or already-exists?
Usefulness Audit Does it integrate and fit? Will it hold up in real use and actually get used?

Findings are concerns, not blocks — the human reviewer decides what to do with them.

value-audit · 20260709T041814Z

@drewstone drewstone merged commit ad527af into main Jul 9, 2026
1 check passed
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