feat(payments): EventDriven per-job billing in the service's settlement asset (native or ERC20)#209
Conversation
…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
left a comment
There was a problem hiding this comment.
✅ 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
left a comment
There was a problem hiding this comment.
🟡 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 (
setBlueprintSettlementAssetinsrc/core/BlueprintsManage.sol:324) and pins it per-service at activation (src/facets/tangle/TangleServicesFacet.sol:199andsrc/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
paymentTokenand usePaymentLib/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 confirmedPaymentLib.collectPaymentalready 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.
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.collectPaymentalready handles both native and hardened ERC20 (fee-on-transfer rejection,msgValue==0enforced);requestService/escrow already carry apaymentToken. Only the EventDriven per-job path hardcodedaddress(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-gatedsetBlueprintSettlementAsset, 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
requestServicefor EventDriven (customer token must beaddress(0)), and closed a second live path — quote-created services (createServiceFromQuotes) also never pinned the asset.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
EventDrivenErc20Settlement.t.sol26/26, incl. the explicit exploit testtest_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._serviceEventDrivenAsset@90,_blueprintSettlementAsset@91,__gap27→25, tail pins (88/89) byte-identical to main — StorageLayoutSnapshotTest 3/3.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)
TangleJobsRFQFacet._distributeRFQJobPaymentstill passesaddress(0)— proven unreachable (no result selector on that facet); delete for hygiene.setBlueprintSettlementAssetvalidates the asset withblueprintIdcontext but collection re-checks withserviceIdcontext; a manager returning different answers per context could brick billing (reverts, no theft). Optional: validate withserviceIdcontext at activation for consistency.