Skip to content

Commit 4ac5f8b

Browse files
Payments rearchitecture: RFQ hardening + multi-asset bill weighting + facet split for EIP-170 (#133)
* fix(quotes): RFQ replay / freshness / cumulative TTL hardening Three security findings on the RFQ quote paths, all closed: - Job RFQ `requester` is signed but was not verified. Thread `msg.sender` through `verifyAndMarkJobQuoteUsed`; reject when the verified address does not match the quote's bound requester. Wildcard requesters (`address(0)`) are also rejected. - Service-creation quotes lacked the freshness check the per-job path enforces. Add the same `block.timestamp <= details.timestamp + maxQuoteAge` gate to `verifyQuoteBatch` so stale quotes cannot be redeemed long after the protocol's max-quote-age window. - `extendServiceFromQuotes` allowed unbounded cumulative TTL. Cap the cumulative service TTL at `MAX_SERVICE_TTL` so repeated extensions cannot push a single service past the protocol's lifetime ceiling. New errors: `JobQuoteRequesterMismatch`, `QuoteTimestampStale`, `CumulativeTtlExceeded`. Tests added in `QuoteVerification.t.sol`, `QuoteExtension.t.sol`, `RFQPaymentDistribution.t.sol` covering each guard. Full RFQ suite 57/57 passes. * feat(payments): multi-asset subscription bill weighting Restore the per-asset commitment system to subscription bills. Previously the bill weighted by a single bond-asset cum-stake-seconds × the operator's overall `ServiceOperator.exposureBps`, collapsing the protocol's multi-asset security model. Now the bill iterates the operator's `AssetSecurityCommitment[]` for the service and sums `cumDelta_op_asset × commitmentBps_asset` (USD-normalized via oracle when configured) across every (op, asset) pair the service requires. - TWAP cursors are now keyed `(serviceId, operator, assetHash)` so per-asset joins, leaves, and rejoins compute correct deltas. - Activation pins a multi-asset baseline of `Σ_op Σ_asset (delegation × commitmentBps × price)`. - Bill amount + operator payout weight share the same per-asset stake-time × exposure aggregate, so customer-fairness and operator-fairness move together across assets. - Fallback: when an operator has no per-asset commitments stored (services that don't opt into the multi-asset commitment system), bill against the bond asset at `ServiceOperator.exposureBps`. Mirrors the legacy single-asset semantics so existing tests / services keep working. Storage: replaces `_twapCursorByOp` (2-level) with `_twapCursorByOpAsset` (3-level). Greenfield rename — no migration. Full regression: 1450 / 1450 ✅. * refactor(facets): split Payments + Services facets to fit EIP-170 Three facets exceeded the 24,576-byte runtime ceiling (TanglePaymentsFacet 31,846, TangleServicesFacet 26,805, TanglePaymentsDistributionFacet 28,971), blocking deployment on any chain that enforces EIP-170. Split the abstract `Payments` contract into focused mixins so each facet inherits only its slice, and pull validation pure-compute out of `ServicesApprovals` into an external library. Payments split: - `PaymentsCore` — shared events, `_activeServiceOperators`, `_depositToEscrow` - `PaymentsEscrow` — `fundService`, `withdrawRemainingEscrow` - `PaymentsBilling` — subscription bill flow, TWAP weighting, `_accrueOperatorWeights` - `PaymentsDistribution` — distribution core, exposure fallback, baseline init - `PaymentsRewards` — already-split rewards/admin (+ `getBillableServices`) Subscription billing's distribution path is reached via a diamond self-call (`ITanglePaymentsInternal.distributeBillWithKeeper`) so the distribution machinery only contributes bytecode to the facet that hosts it. Same for `initSubscriptionBaseline` (now on `TanglePaymentsDistributionFacet`). `hasSecurityCommitments` is now computed inline in `_accrueOperatorWeights` instead of via a second `_calculateEffectiveExposures` pass, which lets `PaymentsBilling` drop its `PaymentsEffectiveExposure` inheritance entirely. Services split: - `AttestationLib` — shared `teeNonce` + `blsPopMessage` pure compute - `ServiceValidationLib` — external library hosting `_validateTeeCommitments`, `_validateSecurityCommitments`, `_requireBlsProofOfPossession` - `ServicesApprovalsViews` — views facet, no longer inherited by `ServicesApprovals` Final runtime sizes (default profile, optimizer on, runs=1): - TanglePaymentsFacet 24,160 (-416 under EIP-170) - TanglePaymentsDistributionFacet 18,144 - TanglePaymentsRewardsFacet 15,266 - TangleServicesFacet 21,741 - ServiceValidationLib 3,802 (deployed separately, auto-linked) No storage layout changes. No diamond-side ABI changes — same selectors, just routed to different physical facet contracts. Full regression: 1,450/1,450 tests pass. --------- Co-authored-by: Drew Stone <drewstone329@gmail.com>
1 parent 8b2777b commit 4ac5f8b

35 files changed

Lines changed: 1856 additions & 1299 deletions

foundry.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ fuzz = { runs = 1_000 }
66
gas_reports = ["*"]
77
libs = ["dependencies"]
88
# optimizer = true (default)
9-
optimizer_runs = 200
9+
optimizer_runs = 1
1010
fs_permissions = [{ access = "read-write", path = "./" }]
1111
solc = "0.8.26"
1212
evm_version = "cancun"

script/DemoSimulation.s.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@ import { TangleBlueprintsManagementFacet } from "../src/facets/tangle/TangleBlue
1919
import { TangleOperatorsFacet } from "../src/facets/tangle/TangleOperatorsFacet.sol";
2020
import { TangleServicesRequestsFacet } from "../src/facets/tangle/TangleServicesRequestsFacet.sol";
2121
import { TangleServicesFacet } from "../src/facets/tangle/TangleServicesFacet.sol";
22+
import { TangleServicesViewsFacet } from "../src/facets/tangle/TangleServicesViewsFacet.sol";
2223
import { TangleServicesLifecycleFacet } from "../src/facets/tangle/TangleServicesLifecycleFacet.sol";
2324
import { TangleJobsFacet } from "../src/facets/tangle/TangleJobsFacet.sol";
2425
import { TangleJobsAggregationFacet } from "../src/facets/tangle/TangleJobsAggregationFacet.sol";
2526
import { TangleJobsRFQFacet } from "../src/facets/tangle/TangleJobsRFQFacet.sol";
2627
import { TangleQuotesFacet } from "../src/facets/tangle/TangleQuotesFacet.sol";
2728
import { TangleQuotesExtensionFacet } from "../src/facets/tangle/TangleQuotesExtensionFacet.sol";
2829
import { TanglePaymentsFacet } from "../src/facets/tangle/TanglePaymentsFacet.sol";
30+
import { TanglePaymentsRewardsFacet } from "../src/facets/tangle/TanglePaymentsRewardsFacet.sol";
31+
import { TanglePaymentsDistributionFacet } from "../src/facets/tangle/TanglePaymentsDistributionFacet.sol";
2932
import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.sol";
3033
import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol";
3134
import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol";
@@ -177,13 +180,16 @@ contract DemoSimulation is Script, BlueprintDefinitionHelper {
177180
router.registerFacet(address(new TangleOperatorsFacet()));
178181
router.registerFacet(address(new TangleServicesRequestsFacet()));
179182
router.registerFacet(address(new TangleServicesFacet()));
183+
router.registerFacet(address(new TangleServicesViewsFacet()));
180184
router.registerFacet(address(new TangleServicesLifecycleFacet()));
181185
router.registerFacet(address(new TangleJobsFacet()));
182186
router.registerFacet(address(new TangleJobsAggregationFacet()));
183187
router.registerFacet(address(new TangleJobsRFQFacet()));
184188
router.registerFacet(address(new TangleQuotesFacet()));
185189
router.registerFacet(address(new TangleQuotesExtensionFacet()));
186190
router.registerFacet(address(new TanglePaymentsFacet()));
191+
router.registerFacet(address(new TanglePaymentsRewardsFacet()));
192+
router.registerFacet(address(new TanglePaymentsDistributionFacet()));
187193
router.registerFacet(address(new TangleSlashingFacet()));
188194
}
189195

script/Deploy.s.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ import { TangleBlueprintsManagementFacet } from "../src/facets/tangle/TangleBlue
1818
import { TangleOperatorsFacet } from "../src/facets/tangle/TangleOperatorsFacet.sol";
1919
import { TangleServicesRequestsFacet } from "../src/facets/tangle/TangleServicesRequestsFacet.sol";
2020
import { TangleServicesFacet } from "../src/facets/tangle/TangleServicesFacet.sol";
21+
import { TangleServicesViewsFacet } from "../src/facets/tangle/TangleServicesViewsFacet.sol";
2122
import { TangleServicesLifecycleFacet } from "../src/facets/tangle/TangleServicesLifecycleFacet.sol";
2223
import { TangleJobsFacet } from "../src/facets/tangle/TangleJobsFacet.sol";
2324
import { TangleJobsAggregationFacet } from "../src/facets/tangle/TangleJobsAggregationFacet.sol";
2425
import { TangleJobsRFQFacet } from "../src/facets/tangle/TangleJobsRFQFacet.sol";
2526
import { TangleQuotesFacet } from "../src/facets/tangle/TangleQuotesFacet.sol";
2627
import { TangleQuotesExtensionFacet } from "../src/facets/tangle/TangleQuotesExtensionFacet.sol";
2728
import { TanglePaymentsFacet } from "../src/facets/tangle/TanglePaymentsFacet.sol";
29+
import { TanglePaymentsRewardsFacet } from "../src/facets/tangle/TanglePaymentsRewardsFacet.sol";
30+
import { TanglePaymentsDistributionFacet } from "../src/facets/tangle/TanglePaymentsDistributionFacet.sol";
2831
import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.sol";
2932
import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol";
3033
import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol";
@@ -398,13 +401,16 @@ contract DeployV2 is DeployScriptBase {
398401
router.registerFacet(address(new TangleOperatorsFacet()));
399402
router.registerFacet(address(new TangleServicesRequestsFacet()));
400403
router.registerFacet(address(new TangleServicesFacet()));
404+
router.registerFacet(address(new TangleServicesViewsFacet()));
401405
router.registerFacet(address(new TangleServicesLifecycleFacet()));
402406
router.registerFacet(address(new TangleJobsFacet()));
403407
router.registerFacet(address(new TangleJobsAggregationFacet()));
404408
router.registerFacet(address(new TangleJobsRFQFacet()));
405409
router.registerFacet(address(new TangleQuotesFacet()));
406410
router.registerFacet(address(new TangleQuotesExtensionFacet()));
407411
router.registerFacet(address(new TanglePaymentsFacet()));
412+
router.registerFacet(address(new TanglePaymentsRewardsFacet()));
413+
router.registerFacet(address(new TanglePaymentsDistributionFacet()));
408414
router.registerFacet(address(new TangleSlashingFacet()));
409415
}
410416

script/DeployContractsOnly.s.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ import { TangleBlueprintsManagementFacet } from "../src/facets/tangle/TangleBlue
1616
import { TangleOperatorsFacet } from "../src/facets/tangle/TangleOperatorsFacet.sol";
1717
import { TangleServicesRequestsFacet } from "../src/facets/tangle/TangleServicesRequestsFacet.sol";
1818
import { TangleServicesFacet } from "../src/facets/tangle/TangleServicesFacet.sol";
19+
import { TangleServicesViewsFacet } from "../src/facets/tangle/TangleServicesViewsFacet.sol";
1920
import { TangleServicesLifecycleFacet } from "../src/facets/tangle/TangleServicesLifecycleFacet.sol";
2021
import { TangleJobsFacet } from "../src/facets/tangle/TangleJobsFacet.sol";
2122
import { TangleJobsAggregationFacet } from "../src/facets/tangle/TangleJobsAggregationFacet.sol";
2223
import { TangleJobsRFQFacet } from "../src/facets/tangle/TangleJobsRFQFacet.sol";
2324
import { TangleQuotesFacet } from "../src/facets/tangle/TangleQuotesFacet.sol";
2425
import { TangleQuotesExtensionFacet } from "../src/facets/tangle/TangleQuotesExtensionFacet.sol";
2526
import { TanglePaymentsFacet } from "../src/facets/tangle/TanglePaymentsFacet.sol";
27+
import { TanglePaymentsRewardsFacet } from "../src/facets/tangle/TanglePaymentsRewardsFacet.sol";
28+
import { TanglePaymentsDistributionFacet } from "../src/facets/tangle/TanglePaymentsDistributionFacet.sol";
2629
import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.sol";
2730
import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol";
2831
import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol";
@@ -127,13 +130,16 @@ contract DeployContractsOnly is Script {
127130
tangle.registerFacet(address(new TangleOperatorsFacet()));
128131
tangle.registerFacet(address(new TangleServicesRequestsFacet()));
129132
tangle.registerFacet(address(new TangleServicesFacet()));
133+
tangle.registerFacet(address(new TangleServicesViewsFacet()));
130134
tangle.registerFacet(address(new TangleServicesLifecycleFacet()));
131135
tangle.registerFacet(address(new TangleJobsFacet()));
132136
tangle.registerFacet(address(new TangleJobsAggregationFacet()));
133137
tangle.registerFacet(address(new TangleJobsRFQFacet()));
134138
tangle.registerFacet(address(new TangleQuotesFacet()));
135139
tangle.registerFacet(address(new TangleQuotesExtensionFacet()));
136140
tangle.registerFacet(address(new TanglePaymentsFacet()));
141+
tangle.registerFacet(address(new TanglePaymentsRewardsFacet()));
142+
tangle.registerFacet(address(new TanglePaymentsDistributionFacet()));
137143
tangle.registerFacet(address(new TangleSlashingFacet()));
138144
}
139145
}

script/LocalTestnet.s.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,16 @@ import { TangleBlueprintsManagementFacet } from "../src/facets/tangle/TangleBlue
3030
import { TangleOperatorsFacet } from "../src/facets/tangle/TangleOperatorsFacet.sol";
3131
import { TangleServicesRequestsFacet } from "../src/facets/tangle/TangleServicesRequestsFacet.sol";
3232
import { TangleServicesFacet } from "../src/facets/tangle/TangleServicesFacet.sol";
33+
import { TangleServicesViewsFacet } from "../src/facets/tangle/TangleServicesViewsFacet.sol";
3334
import { TangleServicesLifecycleFacet } from "../src/facets/tangle/TangleServicesLifecycleFacet.sol";
3435
import { TangleJobsFacet } from "../src/facets/tangle/TangleJobsFacet.sol";
3536
import { TangleJobsAggregationFacet } from "../src/facets/tangle/TangleJobsAggregationFacet.sol";
3637
import { TangleJobsRFQFacet } from "../src/facets/tangle/TangleJobsRFQFacet.sol";
3738
import { TangleQuotesFacet } from "../src/facets/tangle/TangleQuotesFacet.sol";
3839
import { TangleQuotesExtensionFacet } from "../src/facets/tangle/TangleQuotesExtensionFacet.sol";
3940
import { TanglePaymentsFacet } from "../src/facets/tangle/TanglePaymentsFacet.sol";
41+
import { TanglePaymentsRewardsFacet } from "../src/facets/tangle/TanglePaymentsRewardsFacet.sol";
42+
import { TanglePaymentsDistributionFacet } from "../src/facets/tangle/TanglePaymentsDistributionFacet.sol";
4043
import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.sol";
4144
import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol";
4245
import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol";
@@ -1326,13 +1329,16 @@ contract LocalTestnetSetup is Script, BlueprintDefinitionHelper {
13261329
router.registerFacet(address(new TangleOperatorsFacet()));
13271330
router.registerFacet(address(new TangleServicesRequestsFacet()));
13281331
router.registerFacet(address(new TangleServicesFacet()));
1332+
router.registerFacet(address(new TangleServicesViewsFacet()));
13291333
router.registerFacet(address(new TangleServicesLifecycleFacet()));
13301334
router.registerFacet(address(new TangleJobsFacet()));
13311335
router.registerFacet(address(new TangleJobsAggregationFacet()));
13321336
router.registerFacet(address(new TangleJobsRFQFacet()));
13331337
router.registerFacet(address(new TangleQuotesFacet()));
13341338
router.registerFacet(address(new TangleQuotesExtensionFacet()));
13351339
router.registerFacet(address(new TanglePaymentsFacet()));
1340+
router.registerFacet(address(new TanglePaymentsRewardsFacet()));
1341+
router.registerFacet(address(new TanglePaymentsDistributionFacet()));
13361342
router.registerFacet(address(new TangleSlashingFacet()));
13371343
}
13381344

src/TangleStorage.sol

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -410,21 +410,17 @@ abstract contract TangleStorage {
410410
mapping(address => uint256) internal _pendingDisputeBondRefunds;
411411

412412
// ═══════════════════════════════════════════════════════════════════════════
413-
// TWAP SUBSCRIPTION BILLING — PER-OPERATOR CURSORS
414-
// ═══════════════════════════════════════════════════════════════════════════
415-
// Stores the cumulative stake-seconds value last attributed to each
416-
// (service, operator) pair. Replaces the earlier aggregate-only cursor that
417-
// produced a bogus `cumDelta` whenever the active-operator set changed
418-
// between bills (sum-over-old-set vs sum-over-new-set is not a valid delta).
419-
//
420-
// Per-operator cursors make the billing window correct under joins, leaves,
421-
// and rejoins: each operator is attributed only the stake-seconds they
422-
// accrued *while bonded to this service* over the billed period.
423-
424-
/// @notice Service ID => Operator => cumulative stake-seconds at the
425-
/// operator's most recent attribution event for this service
426-
/// (join, prior bill). Zero sentinel = "never attributed."
427-
mapping(uint64 => mapping(address => uint256)) internal _twapCursorByOp;
413+
// TWAP SUBSCRIPTION BILLING — PER-(SERVICE, OPERATOR, ASSET) CURSORS
414+
// ═══════════════════════════════════════════════════════════════════════════
415+
// The bill weight is the integral of `stake × commitmentBps` over the period,
416+
// per (operator, asset) the service requires. Cursors track the cumulative
417+
// stake-seconds last attributed for each (service, op, asset) so cumDelta is
418+
// correct under joins, leaves, rejoins, and per-asset commitment changes.
419+
420+
/// @notice Service ID => Operator => keccak256(asset.kind, asset.token) =>
421+
/// cum stake-seconds at the most recent attribution event (activation
422+
/// seed, join hook, prior bill). Zero sentinel = "never attributed."
423+
mapping(uint64 => mapping(address => mapping(bytes32 => uint256))) internal _twapCursorByOpAsset;
428424

429425
// ═══════════════════════════════════════════════════════════════════════════
430426
// RESERVED STORAGE GAP

src/config/ProtocolConfig.sol

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ library ProtocolConfig {
5757
/// @notice Maximum TTL for service requests (365 days)
5858
uint64 internal constant MAX_SERVICE_TTL = 365 days;
5959

60+
/// @notice Maximum cumulative TTL a service may accumulate across extensions.
61+
/// @dev Per-call validation bounds `additionalTtl` to `MAX_SERVICE_TTL`; without a
62+
/// cumulative cap a long-lived service could grow `svc.ttl` arbitrarily by
63+
/// chaining extensions. Four years is large enough for any realistic
64+
/// production deployment yet small enough to keep escrow exposure bounded.
65+
uint64 internal constant MAX_CUMULATIVE_SERVICE_TTL = 4 * MAX_SERVICE_TTL;
66+
6067
/// @notice Default request expiry grace period (1 hour)
6168
/// @dev Operators have this additional time to approve after expiry
6269
uint64 internal constant REQUEST_EXPIRY_GRACE_PERIOD = 1 hours;

src/core/JobsRFQ.sol

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ abstract contract JobsRFQ is Base {
8282

8383
// Verify quotes and compute total cost
8484
uint64 effectiveMaxQuoteAge = _maxQuoteAge > 0 ? _maxQuoteAge : ProtocolConfig.MAX_QUOTE_AGE;
85-
uint256 totalPrice = _verifyQuotesAndRecordOperators(serviceId, jobIndex, quotes, effectiveMaxQuoteAge);
85+
uint256 totalPrice =
86+
_verifyQuotesAndRecordOperators(serviceId, jobIndex, quotes, effectiveMaxQuoteAge, msg.sender);
8687

8788
if (totalPrice > 0 && !_isPaymentAssetAllowedByManager(bp.manager, serviceId, address(0))) {
8889
revert Errors.TokenNotAllowed(address(0));
@@ -132,7 +133,8 @@ abstract contract JobsRFQ is Base {
132133
uint64 serviceId,
133134
uint8 jobIndex,
134135
Types.SignedJobQuote[] calldata quotes,
135-
uint64 maxQuoteAge
136+
uint64 maxQuoteAge,
137+
address expectedRequester
136138
)
137139
private
138140
returns (uint256 totalPrice)
@@ -174,7 +176,9 @@ abstract contract JobsRFQ is Base {
174176

175177
// Verify EIP-712 signature and mark as used. Domain separator is recomputed
176178
// per-call against current chainid so cross-fork replay is impossible.
177-
SignatureLib.verifyAndMarkJobQuoteUsed(_usedQuotes, _domainSeparatorView(), quote, maxQuoteAge);
179+
SignatureLib.verifyAndMarkJobQuoteUsed(
180+
_usedQuotes, _domainSeparatorView(), quote, maxQuoteAge, expectedRequester
181+
);
178182

179183
totalPrice += quote.details.price;
180184
}

0 commit comments

Comments
 (0)