From a223d8365a4552b1f7f545a57237a8e4c6659af7 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 13 May 2026 08:26:03 -0600 Subject: [PATCH 1/3] 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. --- foundry.toml | 2 +- src/config/ProtocolConfig.sol | 7 +++ src/core/JobsRFQ.sol | 10 ++-- src/core/QuotesCreate.sol | 4 +- src/core/QuotesExtend.sol | 14 ++++- src/libraries/Errors.sol | 17 ++++++ src/libraries/SignatureLib.sol | 28 +++++++++- test/tangle/QuoteExtension.t.sol | 55 +++++++++++++++++++ test/tangle/QuoteVerification.t.sol | 67 ++++++++++++++++++++++++ test/tangle/RFQPaymentDistribution.t.sol | 62 ++++++++++++++++++++++ 10 files changed, 257 insertions(+), 9 deletions(-) diff --git a/foundry.toml b/foundry.toml index 60209443..95954e7a 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,7 +6,7 @@ fuzz = { runs = 1_000 } gas_reports = ["*"] libs = ["dependencies"] # optimizer = true (default) -optimizer_runs = 200 +optimizer_runs = 1 fs_permissions = [{ access = "read-write", path = "./" }] solc = "0.8.26" evm_version = "cancun" diff --git a/src/config/ProtocolConfig.sol b/src/config/ProtocolConfig.sol index c3272bae..fd6e964e 100644 --- a/src/config/ProtocolConfig.sol +++ b/src/config/ProtocolConfig.sol @@ -57,6 +57,13 @@ library ProtocolConfig { /// @notice Maximum TTL for service requests (365 days) uint64 internal constant MAX_SERVICE_TTL = 365 days; + /// @notice Maximum cumulative TTL a service may accumulate across extensions. + /// @dev Per-call validation bounds `additionalTtl` to `MAX_SERVICE_TTL`; without a + /// cumulative cap a long-lived service could grow `svc.ttl` arbitrarily by + /// chaining extensions. Four years is large enough for any realistic + /// production deployment yet small enough to keep escrow exposure bounded. + uint64 internal constant MAX_CUMULATIVE_SERVICE_TTL = 4 * MAX_SERVICE_TTL; + /// @notice Default request expiry grace period (1 hour) /// @dev Operators have this additional time to approve after expiry uint64 internal constant REQUEST_EXPIRY_GRACE_PERIOD = 1 hours; diff --git a/src/core/JobsRFQ.sol b/src/core/JobsRFQ.sol index 06299fd4..71cb6c50 100644 --- a/src/core/JobsRFQ.sol +++ b/src/core/JobsRFQ.sol @@ -82,7 +82,8 @@ abstract contract JobsRFQ is Base { // Verify quotes and compute total cost uint64 effectiveMaxQuoteAge = _maxQuoteAge > 0 ? _maxQuoteAge : ProtocolConfig.MAX_QUOTE_AGE; - uint256 totalPrice = _verifyQuotesAndRecordOperators(serviceId, jobIndex, quotes, effectiveMaxQuoteAge); + uint256 totalPrice = + _verifyQuotesAndRecordOperators(serviceId, jobIndex, quotes, effectiveMaxQuoteAge, msg.sender); if (totalPrice > 0 && !_isPaymentAssetAllowedByManager(bp.manager, serviceId, address(0))) { revert Errors.TokenNotAllowed(address(0)); @@ -132,7 +133,8 @@ abstract contract JobsRFQ is Base { uint64 serviceId, uint8 jobIndex, Types.SignedJobQuote[] calldata quotes, - uint64 maxQuoteAge + uint64 maxQuoteAge, + address expectedRequester ) private returns (uint256 totalPrice) @@ -174,7 +176,9 @@ abstract contract JobsRFQ is Base { // Verify EIP-712 signature and mark as used. Domain separator is recomputed // per-call against current chainid so cross-fork replay is impossible. - SignatureLib.verifyAndMarkJobQuoteUsed(_usedQuotes, _domainSeparatorView(), quote, maxQuoteAge); + SignatureLib.verifyAndMarkJobQuoteUsed( + _usedQuotes, _domainSeparatorView(), quote, maxQuoteAge, expectedRequester + ); totalPrice += quote.details.price; } diff --git a/src/core/QuotesCreate.sol b/src/core/QuotesCreate.sol index 69f290a5..b1a834d9 100644 --- a/src/core/QuotesCreate.sol +++ b/src/core/QuotesCreate.sol @@ -9,6 +9,7 @@ import { Errors } from "../libraries/Errors.sol"; import { SignatureLib } from "../libraries/SignatureLib.sol"; import { IBlueprintServiceManager } from "../interfaces/IBlueprintServiceManager.sol"; import { ITanglePaymentsInternal } from "../interfaces/ITanglePaymentsInternal.sol"; +import { ProtocolConfig } from "../config/ProtocolConfig.sol"; /// @title QuotesCreate /// @notice RFQ service creation from signed quotes @@ -167,8 +168,9 @@ abstract contract QuotesCreate is Base { private returns (uint256 totalCost) { + uint64 effectiveMaxQuoteAge = _maxQuoteAge > 0 ? _maxQuoteAge : ProtocolConfig.MAX_QUOTE_AGE; (totalCost,) = SignatureLib.verifyQuoteBatch( - _usedQuotes, _domainSeparatorView(), quotes, blueprintId, ttl, msg.sender + _usedQuotes, _domainSeparatorView(), quotes, blueprintId, ttl, msg.sender, effectiveMaxQuoteAge ); _ensureQuoteConfidentialityConsistent(quotes); } diff --git a/src/core/QuotesExtend.sol b/src/core/QuotesExtend.sol index fded1086..3c19427c 100644 --- a/src/core/QuotesExtend.sol +++ b/src/core/QuotesExtend.sol @@ -87,8 +87,17 @@ abstract contract QuotesExtend is Base { uint64 extensionStart = currentEndTime > uint64(block.timestamp) ? currentEndTime : uint64(block.timestamp); uint64 oldTtl = svc.ttl; + // Cap the cumulative TTL so chained extensions cannot grow `svc.ttl` past the + // protocol ceiling. Per-call validation already bounds `additionalTtl` to + // `MAX_SERVICE_TTL`, but without a cumulative cap a long-lived service could + // accumulate decades of escrowed runtime one valid extension at a time. + uint64 newTtl = (extensionStart - svc.createdAt) + additionalTtl; + if (newTtl > ProtocolConfig.MAX_CUMULATIVE_SERVICE_TTL) { + revert Errors.CumulativeTtlExceeded(newTtl, ProtocolConfig.MAX_CUMULATIVE_SERVICE_TTL); + } + // Extend TTL - svc.ttl = (extensionStart - svc.createdAt) + additionalTtl; + svc.ttl = newTtl; emit ServiceExtended(serviceId, oldTtl, svc.ttl, totalCost); @@ -145,8 +154,9 @@ abstract contract QuotesExtend is Base { private returns (uint256 totalCost) { + uint64 effectiveMaxQuoteAge = _maxQuoteAge > 0 ? _maxQuoteAge : ProtocolConfig.MAX_QUOTE_AGE; (totalCost,) = SignatureLib.verifyQuoteBatch( - _usedQuotes, _domainSeparatorView(), quotes, blueprintId, ttl, msg.sender + _usedQuotes, _domainSeparatorView(), quotes, blueprintId, ttl, msg.sender, effectiveMaxQuoteAge ); } diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index 8d779347..a47504a5 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -327,6 +327,23 @@ library Errors { /// @notice Quote already used (replay protection) error QuoteAlreadyUsed(address operator); + /// @notice Per-job quote requester does not match the address consuming it. + /// @dev Job-quote digests are single-use; without this check any permitted caller + /// (or mempool observer) could front-run the intended `requester` and consume + /// their digest, redirecting reward credits and the BSM hook context. + error JobQuoteRequesterMismatch(address operator, address expected, address actual); + + /// @notice Quote timestamp older than the protocol's maximum allowed age. + /// @dev Mirrors `QuoteTimestampTooOld` but emitted from the batch service-creation + /// / extension paths where a stale price would lock the operator into a + /// pre-redemption commitment after market conditions changed. + error QuoteTimestampStale(address operator, uint64 timestamp, uint64 maxAge); + + /// @notice Cumulative service TTL would exceed the protocol cap after extension. + /// @dev Per-call validation only bounds the additional TTL; this catches the case + /// where successive extensions grow `svc.ttl` past the long-lived ceiling. + error CumulativeTtlExceeded(uint64 newTtl, uint64 maximum); + /// @notice Insufficient escrow balance error InsufficientEscrowBalance(uint256 required, uint256 available); diff --git a/src/libraries/SignatureLib.sol b/src/libraries/SignatureLib.sol index 3629487c..eeadf06f 100644 --- a/src/libraries/SignatureLib.sol +++ b/src/libraries/SignatureLib.sol @@ -211,14 +211,26 @@ library SignatureLib { } /// @notice Verify job quote signature and mark as used (replay protection) + /// @param expectedRequester The address consuming the quote (typically `msg.sender`). + /// Bound here so a third party that observed the gossiped quote cannot + /// front-run the intended caller and burn the single-use digest. A wildcard + /// `requester == address(0)` on the signed details is rejected outright. function verifyAndMarkJobQuoteUsed( mapping(bytes32 => bool) storage usedQuotes, bytes32 domainSeparator, Types.SignedJobQuote memory quote, - uint64 maxQuoteAge + uint64 maxQuoteAge, + address expectedRequester ) internal { + // Bind the quote to the caller. Wildcard requesters are rejected because a + // publicly-posted wildcard quote is a free coupon for whoever lands their tx + // first — the operator's signature must commit to a specific consumer. + if (quote.details.requester == address(0) || quote.details.requester != expectedRequester) { + revert Errors.JobQuoteRequesterMismatch(quote.operator, quote.details.requester, expectedRequester); + } + // Check expiry if (block.timestamp > quote.details.expiry) { revert Errors.QuoteExpired(quote.operator, quote.details.expiry); @@ -253,6 +265,11 @@ library SignatureLib { /// @notice Verify multiple quotes and compute total cost. /// @param expectedRequester The address each quote must be bound to (typically `msg.sender`). + /// @param maxQuoteAge Maximum allowed age (in seconds) of `details.timestamp`. `0` disables + /// the check; non-zero values enforce that the operator-signed timestamp is no older + /// than `maxQuoteAge` at redemption. Without this an operator-signed `expiry` set to + /// `type(uint64).max` would let a customer redeem a stale price long after the + /// market moved. /// @dev Wildcard `requester == address(0)` is rejected. Operators that sign a wildcard /// quote and post it publicly are vulnerable to a front-runner consuming the /// single-use digest before the intended caller's tx lands. Wildcard support has @@ -265,7 +282,8 @@ library SignatureLib { Types.SignedQuote[] memory quotes, uint64 blueprintId, uint64 ttl, - address expectedRequester + address expectedRequester, + uint64 maxQuoteAge ) internal returns (uint256 totalCost, address[] memory operators) @@ -301,6 +319,12 @@ library SignatureLib { revert Errors.QuoteExpired(quote.operator, quote.details.expiry); } + // Check timestamp freshness so an operator-signed long-tail `expiry` cannot + // be used to redeem a stale-priced quote weeks later. + if (maxQuoteAge > 0 && block.timestamp > quote.details.timestamp + maxQuoteAge) { + revert Errors.QuoteTimestampStale(quote.operator, quote.details.timestamp, maxQuoteAge); + } + // Bind quote to the intended requester so a third party cannot front-run // `createServiceFromQuotes` with the operator's signature. Wildcard // `requester == address(0)` is rejected outright — see the docstring. diff --git a/test/tangle/QuoteExtension.t.sol b/test/tangle/QuoteExtension.t.sol index 4b36d03d..f7510c76 100644 --- a/test/tangle/QuoteExtension.t.sol +++ b/test/tangle/QuoteExtension.t.sol @@ -6,6 +6,7 @@ import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/Mes import { BaseTest } from "../BaseTest.sol"; import { Types } from "../../src/libraries/Types.sol"; import { Errors } from "../../src/libraries/Errors.sol"; +import { ProtocolConfig } from "../../src/config/ProtocolConfig.sol"; /// @title QuoteExtensionTest /// @notice Tests for service TTL extension via quotes @@ -275,6 +276,60 @@ contract QuoteExtensionTest is BaseTest { assertGe(newEnd, block.timestamp + additionalTtl - 1, "Should extend from current time if expired"); } + // ═══════════════════════════════════════════════════════════════════════════ + // CUMULATIVE TTL CAP + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice A sequence of legal-sized extensions still reverts once the cumulative + /// service TTL would exceed `MAX_CUMULATIVE_SERVICE_TTL`. + /// @dev Per-call validation already bounds `additionalTtl` to `MAX_SERVICE_TTL` (365d); + /// this test asserts that the cumulative cap (`MAX_CUMULATIVE_SERVICE_TTL`, 4x) + /// stops chained extensions from growing `svc.ttl` past the long-lived ceiling. + function test_ExtendService_RevertsOnCumulativeTtlExceeded() public { + uint64 maxPerCall = ProtocolConfig.MAX_SERVICE_TTL; + uint64 cumulativeCap = ProtocolConfig.MAX_CUMULATIVE_SERVICE_TTL; + + // Each extension must use a fresh quote digest (replay protection burns the + // digest at first use), and the per-quote totalCost varies so two operator- + // signed quotes never collide in the digest set. + Types.Service memory svc = tangle.getService(serviceId); + uint256 iter = 0; + while (true) { + uint64 currentEnd = svc.createdAt + svc.ttl; + uint64 extensionStart = currentEnd > uint64(block.timestamp) ? currentEnd : uint64(block.timestamp); + uint64 prospectiveTtl = (extensionStart - svc.createdAt) + maxPerCall; + if (prospectiveTtl > cumulativeCap) break; + + iter++; + uint256 cost = 0.5 ether + iter; // unique per iteration so digests differ + Types.SignedQuote[] memory quotes = new Types.SignedQuote[](1); + quotes[0] = _createExtensionQuote(operator1Key, operator1, cost, maxPerCall); + + vm.deal(user1, cost); + vm.prank(user1); + tangle.extendServiceFromQuotes{ value: cost }(serviceId, quotes, maxPerCall); + + svc = tangle.getService(serviceId); + } + + // Next legal-sized extension would push svc.ttl over the cumulative cap. + uint64 currentEndFinal = svc.createdAt + svc.ttl; + uint64 extensionStartFinal = + currentEndFinal > uint64(block.timestamp) ? currentEndFinal : uint64(block.timestamp); + uint64 expectedNewTtl = (extensionStartFinal - svc.createdAt) + maxPerCall; + assertGt(expectedNewTtl, cumulativeCap, "test setup: next extension should exceed cap"); + + iter++; + uint256 finalCost = 0.5 ether + iter; + Types.SignedQuote[] memory finalQuotes = new Types.SignedQuote[](1); + finalQuotes[0] = _createExtensionQuote(operator1Key, operator1, finalCost, maxPerCall); + + vm.deal(user1, finalCost); + vm.prank(user1); + vm.expectRevert(abi.encodeWithSelector(Errors.CumulativeTtlExceeded.selector, expectedNewTtl, cumulativeCap)); + tangle.extendServiceFromQuotes{ value: finalCost }(serviceId, finalQuotes, maxPerCall); + } + // ═══════════════════════════════════════════════════════════════════════════ // EVENT EMISSION // ═══════════════════════════════════════════════════════════════════════════ diff --git a/test/tangle/QuoteVerification.t.sol b/test/tangle/QuoteVerification.t.sol index e926ed78..a56f470b 100644 --- a/test/tangle/QuoteVerification.t.sol +++ b/test/tangle/QuoteVerification.t.sol @@ -418,6 +418,73 @@ contract QuoteVerificationTest is BaseTest { tangle.createServiceFromQuotes{ value: 1 ether }(managedBlueprintId, quotes, "", new address[](0), 100); } + // ═══════════════════════════════════════════════════════════════════════════ + // TIMESTAMP FRESHNESS + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice Operator signs a long-lived `expiry`; redemption past `maxQuoteAge` must revert. + /// @dev Without the freshness check, an operator-signed `expiry = type(uint64).max` + /// would let the customer redeem a stale-priced quote weeks later. + function test_CreateFromQuote_RevertStaleTimestampPastMaxAge() public { + uint64 maxAge = 1 hours; + uint64 baseTimestamp = uint64(block.timestamp); + + Types.QuoteDetails memory details = Types.QuoteDetails({ + requester: user1, + blueprintId: blueprintId, + ttlBlocks: 100, + totalCost: 1 ether, + timestamp: baseTimestamp, + expiry: type(uint64).max, + confidentiality: Types.ConfidentialityPolicy.Any, + securityCommitments: new Types.AssetSecurityCommitment[](0), + resourceCommitments: new Types.ResourceCommitment[](0) + }); + + bytes memory signature = _signQuote(details, OPERATOR1_PK); + + Types.SignedQuote[] memory quotes = new Types.SignedQuote[](1); + quotes[0] = Types.SignedQuote({ details: details, signature: signature, operator: operator1 }); + + // Warp past `maxAge` after timestamp, but well before the operator's `expiry`. + vm.warp(uint256(baseTimestamp) + uint256(maxAge) + 1); + + vm.prank(user1); + vm.expectRevert(abi.encodeWithSelector(Errors.QuoteTimestampStale.selector, operator1, baseTimestamp, maxAge)); + tangle.createServiceFromQuotes{ value: 1 ether }(blueprintId, quotes, "", new address[](0), 100); + } + + /// @notice A quote within `maxQuoteAge` is still redeemable. + function test_CreateFromQuote_FreshTimestampAccepted() public { + uint64 maxAge = 1 hours; + uint64 baseTimestamp = uint64(block.timestamp); + + Types.QuoteDetails memory details = Types.QuoteDetails({ + requester: user1, + blueprintId: blueprintId, + ttlBlocks: 100, + totalCost: 1 ether, + timestamp: baseTimestamp, + expiry: type(uint64).max, + confidentiality: Types.ConfidentialityPolicy.Any, + securityCommitments: new Types.AssetSecurityCommitment[](0), + resourceCommitments: new Types.ResourceCommitment[](0) + }); + + bytes memory signature = _signQuote(details, OPERATOR1_PK); + + Types.SignedQuote[] memory quotes = new Types.SignedQuote[](1); + quotes[0] = Types.SignedQuote({ details: details, signature: signature, operator: operator1 }); + + // Warp just under `maxAge` — still fresh. + vm.warp(uint256(baseTimestamp) + uint256(maxAge) - 1); + + vm.prank(user1); + uint64 serviceId = + tangle.createServiceFromQuotes{ value: 1 ether }(blueprintId, quotes, "", new address[](0), 100); + assertTrue(tangle.isServiceActive(serviceId)); + } + // ═══════════════════════════════════════════════════════════════════════════ // REPLAY PROTECTION // ═══════════════════════════════════════════════════════════════════════════ diff --git a/test/tangle/RFQPaymentDistribution.t.sol b/test/tangle/RFQPaymentDistribution.t.sol index 3c2ab655..b5903849 100644 --- a/test/tangle/RFQPaymentDistribution.t.sol +++ b/test/tangle/RFQPaymentDistribution.t.sol @@ -596,6 +596,68 @@ contract RFQPaymentDistributionTest is BaseTest { assertEq(devGot + treasuryGot + opPending, payment, "odd payment: no dust lost"); } + // ═══════════════════════════════════════════════════════════════════════════ + // REQUESTER BINDING (front-run protection) + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice A second permitted caller cannot consume a quote signed for user1. + /// @dev The operator gossips the quote to user1; user2 observes it in the mempool + /// (or via any out-of-band channel) and tries to land the call first. The + /// verification must reject before the single-use digest is burned. + function test_SubmitJobFromQuote_FrontRunByOtherPermittedCallerReverts() public { + // Add user2 as a permitted caller on the service. + vm.prank(user1); + tangle.addPermittedCaller(serviceId, user2); + vm.deal(user2, 10 ether); + + uint256 price = 1 ether; + + // Operator signs a quote bound to user1. + Types.SignedJobQuote[] memory quotes = new Types.SignedJobQuote[](1); + quotes[0] = _createJobQuote(operator1, OPERATOR1_PK, serviceId, 0, price); + + // user2 tries to consume the user1-bound quote — must revert with mismatch. + vm.prank(user2); + vm.expectRevert(abi.encodeWithSelector(Errors.JobQuoteRequesterMismatch.selector, operator1, user1, user2)); + tangle.submitJobFromQuote{ value: price }(serviceId, 0, "", quotes); + + // user1 (the intended consumer) can still redeem the same quote — digest + // was not burned by the failed front-run attempt. + vm.prank(user1); + uint64 callId = tangle.submitJobFromQuote{ value: price }(serviceId, 0, "", quotes); + + // Recorded caller is user1, not user2. + Types.JobCall memory call = tangle.getJobCall(serviceId, callId); + assertEq(call.caller, user1, "caller recorded as user1"); + } + + /// @notice A wildcard `requester = address(0)` quote is rejected outright. + function test_SubmitJobFromQuote_WildcardRequesterReverts() public { + uint256 price = 1 ether; + + // Build a quote with requester = address(0) and sign it. + uint64 baseTimestamp = uint64(block.timestamp); + Types.JobQuoteDetails memory details = Types.JobQuoteDetails({ + requester: address(0), + serviceId: serviceId, + jobIndex: 0, + price: price, + timestamp: baseTimestamp, + expiry: baseTimestamp + 1 hours, + confidentiality: 0 + }); + bytes memory signature = _signJobQuote(details, OPERATOR1_PK); + + Types.SignedJobQuote[] memory quotes = new Types.SignedJobQuote[](1); + quotes[0] = Types.SignedJobQuote({ details: details, signature: signature, operator: operator1 }); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSelector(Errors.JobQuoteRequesterMismatch.selector, operator1, address(0), user1) + ); + tangle.submitJobFromQuote{ value: price }(serviceId, 0, "", quotes); + } + // ═══════════════════════════════════════════════════════════════════════════ // HELPERS // ═══════════════════════════════════════════════════════════════════════════ From 5d4e3825959fe4cc2008f47b3197c26c79bfcdb2 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 13 May 2026 11:02:37 -0600 Subject: [PATCH 2/3] feat(payments): multi-asset subscription bill weighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ✅. --- src/TangleStorage.sol | 26 ++--- src/core/Payments.sol | 186 ++++++++++++++++++++++++++------- src/core/ServicesLifecycle.sol | 32 ++++-- 3 files changed, 181 insertions(+), 63 deletions(-) diff --git a/src/TangleStorage.sol b/src/TangleStorage.sol index 333500e5..13ae1d32 100644 --- a/src/TangleStorage.sol +++ b/src/TangleStorage.sol @@ -410,21 +410,17 @@ abstract contract TangleStorage { mapping(address => uint256) internal _pendingDisputeBondRefunds; // ═══════════════════════════════════════════════════════════════════════════ - // TWAP SUBSCRIPTION BILLING — PER-OPERATOR CURSORS - // ═══════════════════════════════════════════════════════════════════════════ - // Stores the cumulative stake-seconds value last attributed to each - // (service, operator) pair. Replaces the earlier aggregate-only cursor that - // produced a bogus `cumDelta` whenever the active-operator set changed - // between bills (sum-over-old-set vs sum-over-new-set is not a valid delta). - // - // Per-operator cursors make the billing window correct under joins, leaves, - // and rejoins: each operator is attributed only the stake-seconds they - // accrued *while bonded to this service* over the billed period. - - /// @notice Service ID => Operator => cumulative stake-seconds at the - /// operator's most recent attribution event for this service - /// (join, prior bill). Zero sentinel = "never attributed." - mapping(uint64 => mapping(address => uint256)) internal _twapCursorByOp; + // TWAP SUBSCRIPTION BILLING — PER-(SERVICE, OPERATOR, ASSET) CURSORS + // ═══════════════════════════════════════════════════════════════════════════ + // The bill weight is the integral of `stake × commitmentBps` over the period, + // per (operator, asset) the service requires. Cursors track the cumulative + // stake-seconds last attributed for each (service, op, asset) so cumDelta is + // correct under joins, leaves, rejoins, and per-asset commitment changes. + + /// @notice Service ID => Operator => keccak256(asset.kind, asset.token) => + /// cum stake-seconds at the most recent attribution event (activation + /// seed, join hook, prior bill). Zero sentinel = "never attributed." + mapping(uint64 => mapping(address => mapping(bytes32 => uint256))) internal _twapCursorByOpAsset; // ═══════════════════════════════════════════════════════════════════════════ // RESERVED STORAGE GAP diff --git a/src/core/Payments.sol b/src/core/Payments.sol index 3c1d50b4..59e2eaa6 100644 --- a/src/core/Payments.sol +++ b/src/core/Payments.sol @@ -11,6 +11,7 @@ import { PaymentLib } from "../libraries/PaymentLib.sol"; import { IBlueprintServiceManager } from "../interfaces/IBlueprintServiceManager.sol"; import { IServiceFeeDistributor } from "../interfaces/IServiceFeeDistributor.sol"; import { IStaking } from "../interfaces/IStaking.sol"; +import { IPriceOracle } from "../oracles/interfaces/IPriceOracle.sol"; /// @title Payments /// @notice Payment distribution, escrow, and rewards @@ -316,7 +317,7 @@ abstract contract Payments is Base, PaymentsEffectiveExposure { // From here every exit path commits the cursors and advances `lastPaymentAt`. // Cursor SSTOREs land BEFORE any external transfer in `_distributeBill` so a // reverting transfer never leaves cursors stale. - _commitOperatorCursors(serviceId, operators, w.projectedCursors); + _commitOperatorCursors(serviceId, operators, w.projectedByOpAsset); svc.lastPaymentAt = periodEnd; // Skip-on-dust: a bill that rounds to less than 1 wei per recipient is treated @@ -357,55 +358,111 @@ abstract contract Payments is Base, PaymentsEffectiveExposure { function _commitOperatorCursors( uint64 serviceId, address[] memory operators, - uint256[] memory projectedCursors + uint256[][] memory projectedByOpAsset ) internal { + Types.Asset memory bondAsset = _bondAssetForBilling(); for (uint256 i = 0; i < operators.length;) { - _twapCursorByOp[serviceId][operators[i]] = projectedCursors[i]; + address op = operators[i]; + Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; + uint256[] memory projected = projectedByOpAsset[i]; + uint256 m = commitments.length; + if (m == 0) { + // Fallback path: single bond-asset cursor. `projected` was sized to 1 by + // `_accrueOperatorWeights` to mirror this shape. + bytes32 assetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); + _twapCursorByOpAsset[serviceId][op][assetHash] = projected[0]; + } else { + for (uint256 j = 0; j < m;) { + bytes32 assetHash = keccak256(abi.encode(commitments[j].asset.kind, commitments[j].asset.token)); + _twapCursorByOpAsset[serviceId][op][assetHash] = projected[j]; + unchecked { + ++j; + } + } + } unchecked { ++i; } } } - /// @notice Initialize per-operator TWAP cursors and pin the baseline at service activation. - /// @dev Called from `_activateService` for Subscription-pricing services. After this call, - /// the very next bill — at any point in the future — will measure cumDelta against - /// the activation snapshot, so the price the customer agreed to is the price they pay. + /// @notice Initialize per-(operator, asset) TWAP cursors and pin the multi-asset baseline. + /// @dev Walks each operator's `AssetSecurityCommitment[]` and seeds cursors for every + /// (op, asset) pair. Baseline is the exposure-weighted aggregate + /// `Σ_op Σ_asset (delegation × commitmentBps)`, USD-normalized when a price oracle + /// is configured. Pinned once at activation; subsequent bills measure against this + /// snapshot so an operator cannot inflate the customer's bill by ramping stake on + /// a single asset post-activation. function _initSubscriptionBaseline(uint64 serviceId, address[] calldata operators) internal { IStaking staking = _getStaking(); + address oracleAddr = _getPriceOracle(); + bool useOracle = oracleAddr != address(0); + IPriceOracle oracle = IPriceOracle(oracleAddr); Types.Asset memory bondAsset = _bondAssetForBilling(); + uint256 baseline; uint256 n = operators.length; for (uint256 i = 0; i < n;) { - (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(operators[i], bondAsset); - // Sentinel 1 substitutes for a genuine-zero cum so the cursor stays "set". - _twapCursorByOp[serviceId][operators[i]] = cumOp == 0 ? 1 : cumOp; - baseline += stakeOp; + address op = operators[i]; + Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; + uint256 m = commitments.length; + if (m == 0) { + // No per-asset commitments specified — fall back to the bond asset at + // the operator's `ServiceOperator.exposureBps`. Mirrors the legacy + // single-asset semantics for services that don't opt into the + // multi-asset commitment system. + bytes32 assetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); + (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, bondAsset); + _twapCursorByOpAsset[serviceId][op][assetHash] = cumOp == 0 ? 1 : cumOp; + uint16 fallbackBps = _serviceOperators[serviceId][op].exposureBps; + if (fallbackBps == 0) fallbackBps = uint16(BPS_DENOMINATOR); + uint256 exposedAmount = (stakeOp * uint256(fallbackBps)) / BPS_DENOMINATOR; + if (useOracle && exposedAmount > 0) { + address token = bondAsset.kind == Types.AssetKind.Native ? address(0) : bondAsset.token; + baseline += oracle.toUSD(token, exposedAmount); + } else { + baseline += exposedAmount; + } + } else { + for (uint256 j = 0; j < m;) { + Types.AssetSecurityCommitment storage c = commitments[j]; + bytes32 assetHash = keccak256(abi.encode(c.asset.kind, c.asset.token)); + (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, c.asset); + _twapCursorByOpAsset[serviceId][op][assetHash] = cumOp == 0 ? 1 : cumOp; + + uint256 exposedAmount = (stakeOp * uint256(c.exposureBps)) / BPS_DENOMINATOR; + if (useOracle && exposedAmount > 0) { + address token = c.asset.kind == Types.AssetKind.Native ? address(0) : c.asset.token; + baseline += oracle.toUSD(token, exposedAmount); + } else { + baseline += exposedAmount; + } + unchecked { + ++j; + } + } + } unchecked { ++i; } } - // Pathological zero-stake activation: defensive minimum of 1 wei keeps the - // denominator positive. In practice activation requires staked operators. + // Pathological zero-stake activation: defensive minimum of 1 keeps the denominator + // positive. In practice activation requires staked, committed operators. uint256 pinned = baseline == 0 ? 1 : baseline; _serviceEscrows[serviceId].subscriptionBaselineStake = pinned; emit SubscriptionBaselineInitialized(serviceId, pinned, n); } - /// @notice One-pass: per-operator cum delta, weights for distribution, and exposure metadata. - /// @dev Weights = cumDelta_op × exposureBps_op. An operator that staked longer at higher - /// stake AND held more exposure earns proportionally more of the operator pool. This - /// uses the same TWAP cursors that drive the bill amount, so customer-fairness and - /// operator-fairness are linked: an operator who ramped stake just before billing - /// raises both the bill (via cumDelta) AND their share of it (via weight) — neither - /// side is gameable in isolation. - /// @notice Bundle of per-bill state computed from per-operator stake-seconds. + /// @notice Per-bill state computed from per-(operator, asset) stake-seconds. + /// @dev `projectedByOpAsset[i]` is a jagged inner array indexed by the operator's + /// `AssetSecurityCommitment[j]`. Cursors are committed only after the bill passes + /// the escrow-balance check so a failed try-bill cannot advance state. struct BillWeights { uint256 cumDeltaPeriod; - uint256[] weights; - uint256[] projectedCursors; + uint256[] weights; // per-operator (exposure-weighted across assets) + uint256[][] projectedByOpAsset; // per-(operator, asset_in_commitments) uint256 totalWeight; bool hasSecurityCommitments; } @@ -420,27 +477,78 @@ abstract contract Payments is Base, PaymentsEffectiveExposure { returns (BillWeights memory result) { IStaking staking = _getStaking(); + address oracleAddr = _getPriceOracle(); + bool useOracle = oracleAddr != address(0); + IPriceOracle oracle = IPriceOracle(oracleAddr); Types.Asset memory bondAsset = _bondAssetForBilling(); uint256 tailSeconds = block.timestamp > periodEnd ? block.timestamp - uint256(periodEnd) : 0; - result.weights = new uint256[](operators.length); - result.projectedCursors = new uint256[](operators.length); + uint256 n = operators.length; + result.weights = new uint256[](n); + result.projectedByOpAsset = new uint256[][](n); - for (uint256 i = 0; i < operators.length;) { - (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(operators[i], bondAsset); - uint256 projected = _projectToPeriodEnd(cumOp, stakeOp, tailSeconds); - uint256 cursor = _twapCursorByOp[serviceId][operators[i]]; - uint256 opDelta; - if (cursor != 0 && projected > cursor) { - opDelta = projected - cursor; + for (uint256 i = 0; i < n;) { + address op = operators[i]; + Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; + uint256 m = commitments.length; + uint256 opWeight; + uint256[] memory projected; + if (m == 0) { + // Fallback: no per-asset commitments → treat as a single implicit + // commitment to the bond asset at the operator's overall + // `ServiceOperator.exposureBps`. Mirrors `_initSubscriptionBaseline`. + projected = new uint256[](1); + bytes32 assetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); + (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, bondAsset); + uint256 projectedCum = _projectToPeriodEnd(cumOp, stakeOp, tailSeconds); + uint256 cursor = _twapCursorByOpAsset[serviceId][op][assetHash]; + uint256 opDeltaRaw; + if (cursor != 0 && projectedCum > cursor) { + opDeltaRaw = projectedCum - cursor; + } + projected[0] = projectedCum; + + uint16 fallbackBps = _serviceOperators[serviceId][op].exposureBps; + if (fallbackBps == 0) fallbackBps = uint16(BPS_DENOMINATOR); + uint256 contribution = (opDeltaRaw * uint256(fallbackBps)) / BPS_DENOMINATOR; + if (useOracle && contribution > 0) { + address token = bondAsset.kind == Types.AssetKind.Native ? address(0) : bondAsset.token; + contribution = oracle.toUSD(token, contribution); + } + opWeight = contribution; + } else { + projected = new uint256[](m); + for (uint256 j = 0; j < m;) { + Types.AssetSecurityCommitment storage c = commitments[j]; + bytes32 assetHash = keccak256(abi.encode(c.asset.kind, c.asset.token)); + (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, c.asset); + uint256 projectedCum = _projectToPeriodEnd(cumOp, stakeOp, tailSeconds); + uint256 cursor = _twapCursorByOpAsset[serviceId][op][assetHash]; + uint256 opDeltaRaw; + if (cursor != 0 && projectedCum > cursor) { + opDeltaRaw = projectedCum - cursor; + } + projected[j] = projectedCum; + + // Exposure-weighted contribution. With oracle: USD-normalized so + // heterogeneous assets aggregate by value. Without oracle: raw + // token-second amounts — comparable only when all committed assets + // share a unit, but proportional within that unit. + uint256 contribution = (opDeltaRaw * uint256(c.exposureBps)) / BPS_DENOMINATOR; + if (useOracle && contribution > 0) { + address token = c.asset.kind == Types.AssetKind.Native ? address(0) : c.asset.token; + contribution = oracle.toUSD(token, contribution); + } + opWeight += contribution; + unchecked { + ++j; + } + } } - result.projectedCursors[i] = projected; - result.cumDeltaPeriod += opDelta; - - uint16 exposureBps = _serviceOperators[serviceId][operators[i]].exposureBps; - uint256 w = opDelta * uint256(exposureBps); - result.weights[i] = w; - result.totalWeight += w; + result.projectedByOpAsset[i] = projected; + result.weights[i] = opWeight; + result.totalWeight += opWeight; + result.cumDeltaPeriod += opWeight; unchecked { ++i; } diff --git a/src/core/ServicesLifecycle.sol b/src/core/ServicesLifecycle.sol index 1590ba62..a5bd1a73 100644 --- a/src/core/ServicesLifecycle.sol +++ b/src/core/ServicesLifecycle.sol @@ -675,15 +675,29 @@ abstract contract ServicesLifecycle is Base { svc.operatorCount++; _operatorActiveServiceCount[svc.blueprintId][msg.sender]++; - // Re-seed this (service, operator) pair's TWAP cursor at the join - // instant. A rejoiner whose cursor still held its pre-leave value would - // otherwise be billed for off-service cum growth on the next bill. We - // overwrite unconditionally (not via the idempotent helper) because - // re-join MUST clear any stale cursor. Sentinel 1 substitutes for a - // genuine-zero cum so the cursor remains "set" — staking cum is - // monotonic, so the next bill computes a correct delta from this floor. - (uint256 cumOpAtJoin,,) = _staking.getCumStakeSeconds(msg.sender, _bondAssetForBilling()); - _twapCursorByOp[serviceId][msg.sender] = cumOpAtJoin == 0 ? 1 : cumOpAtJoin; + // Re-seed this (service, operator) pair's per-asset TWAP cursors at the + // join instant. A rejoiner whose cursors still held pre-leave values would + // be billed for off-service cum growth on the next bill. Sentinel 1 + // substitutes for genuine-zero cum so the cursor stays "set". + Types.AssetSecurityCommitment[] storage joinerCommitments = _serviceSecurityCommitments[serviceId][msg.sender]; + uint256 commitmentCount = joinerCommitments.length; + if (commitmentCount == 0) { + // Fallback: single bond-asset cursor (matches `_initSubscriptionBaseline`). + Types.Asset memory bondAsset = _bondAssetForBilling(); + bytes32 assetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); + (uint256 cumOpAtJoin,,) = _staking.getCumStakeSeconds(msg.sender, bondAsset); + _twapCursorByOpAsset[serviceId][msg.sender][assetHash] = cumOpAtJoin == 0 ? 1 : cumOpAtJoin; + } else { + for (uint256 k = 0; k < commitmentCount;) { + Types.AssetSecurityCommitment storage c = joinerCommitments[k]; + bytes32 assetHash = keccak256(abi.encode(c.asset.kind, c.asset.token)); + (uint256 cumOpAtJoin,,) = _staking.getCumStakeSeconds(msg.sender, c.asset); + _twapCursorByOpAsset[serviceId][msg.sender][assetHash] = cumOpAtJoin == 0 ? 1 : cumOpAtJoin; + unchecked { + ++k; + } + } + } if (_operatorStatusRegistry != address(0)) { try IOperatorStatusRegistry(_operatorStatusRegistry).registerOperator(serviceId, msg.sender) { } catch { } From 06468b0b17987de9850fcc108862c84265db2e88 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 14 May 2026 13:19:08 -0600 Subject: [PATCH 3/3] refactor(facets): split Payments + Services facets to fit EIP-170 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- script/DemoSimulation.s.sol | 6 + script/Deploy.s.sol | 6 + script/DeployContractsOnly.s.sol | 6 + script/LocalTestnet.s.sol | 6 + src/core/Payments.sol | 1104 ----------------- src/core/PaymentsBilling.sol | 404 ++++++ src/core/PaymentsCore.sol | 66 + src/core/PaymentsDistribution.sol | 408 ++++++ src/core/PaymentsEscrow.sol | 66 + src/core/PaymentsRewards.sol | 175 +++ src/core/ServicesApprovals.sol | 170 +-- src/core/ServicesApprovalsViews.sol | 83 ++ .../TanglePaymentsDistributionFacet.sol | 87 ++ src/facets/tangle/TanglePaymentsFacet.sol | 76 +- .../tangle/TanglePaymentsRewardsFacet.sol | 28 + src/facets/tangle/TangleServicesFacet.sol | 38 +- .../tangle/TangleServicesViewsFacet.sol | 21 + src/interfaces/ITanglePaymentsInternal.sol | 29 +- src/libraries/AttestationLib.sol | 42 + src/libraries/ServiceValidationLib.sol | 100 ++ test/BaseTest.sol | 6 + test/blueprints/TestHarness.sol | 6 + test/fuzz/InvariantFuzz.t.sol | 6 + 23 files changed, 1565 insertions(+), 1374 deletions(-) delete mode 100644 src/core/Payments.sol create mode 100644 src/core/PaymentsBilling.sol create mode 100644 src/core/PaymentsCore.sol create mode 100644 src/core/PaymentsDistribution.sol create mode 100644 src/core/PaymentsEscrow.sol create mode 100644 src/core/PaymentsRewards.sol create mode 100644 src/core/ServicesApprovalsViews.sol create mode 100644 src/facets/tangle/TanglePaymentsDistributionFacet.sol create mode 100644 src/facets/tangle/TanglePaymentsRewardsFacet.sol create mode 100644 src/facets/tangle/TangleServicesViewsFacet.sol create mode 100644 src/libraries/AttestationLib.sol create mode 100644 src/libraries/ServiceValidationLib.sol diff --git a/script/DemoSimulation.s.sol b/script/DemoSimulation.s.sol index b56250a0..4e91c720 100644 --- a/script/DemoSimulation.s.sol +++ b/script/DemoSimulation.s.sol @@ -19,6 +19,7 @@ import { TangleBlueprintsManagementFacet } from "../src/facets/tangle/TangleBlue import { TangleOperatorsFacet } from "../src/facets/tangle/TangleOperatorsFacet.sol"; import { TangleServicesRequestsFacet } from "../src/facets/tangle/TangleServicesRequestsFacet.sol"; import { TangleServicesFacet } from "../src/facets/tangle/TangleServicesFacet.sol"; +import { TangleServicesViewsFacet } from "../src/facets/tangle/TangleServicesViewsFacet.sol"; import { TangleServicesLifecycleFacet } from "../src/facets/tangle/TangleServicesLifecycleFacet.sol"; import { TangleJobsFacet } from "../src/facets/tangle/TangleJobsFacet.sol"; import { TangleJobsAggregationFacet } from "../src/facets/tangle/TangleJobsAggregationFacet.sol"; @@ -26,6 +27,8 @@ import { TangleJobsRFQFacet } from "../src/facets/tangle/TangleJobsRFQFacet.sol" import { TangleQuotesFacet } from "../src/facets/tangle/TangleQuotesFacet.sol"; import { TangleQuotesExtensionFacet } from "../src/facets/tangle/TangleQuotesExtensionFacet.sol"; import { TanglePaymentsFacet } from "../src/facets/tangle/TanglePaymentsFacet.sol"; +import { TanglePaymentsRewardsFacet } from "../src/facets/tangle/TanglePaymentsRewardsFacet.sol"; +import { TanglePaymentsDistributionFacet } from "../src/facets/tangle/TanglePaymentsDistributionFacet.sol"; import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.sol"; import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; @@ -177,6 +180,7 @@ contract DemoSimulation is Script, BlueprintDefinitionHelper { router.registerFacet(address(new TangleOperatorsFacet())); router.registerFacet(address(new TangleServicesRequestsFacet())); router.registerFacet(address(new TangleServicesFacet())); + router.registerFacet(address(new TangleServicesViewsFacet())); router.registerFacet(address(new TangleServicesLifecycleFacet())); router.registerFacet(address(new TangleJobsFacet())); router.registerFacet(address(new TangleJobsAggregationFacet())); @@ -184,6 +188,8 @@ contract DemoSimulation is Script, BlueprintDefinitionHelper { router.registerFacet(address(new TangleQuotesFacet())); router.registerFacet(address(new TangleQuotesExtensionFacet())); router.registerFacet(address(new TanglePaymentsFacet())); + router.registerFacet(address(new TanglePaymentsRewardsFacet())); + router.registerFacet(address(new TanglePaymentsDistributionFacet())); router.registerFacet(address(new TangleSlashingFacet())); } diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 5b2dd670..1e080887 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -18,6 +18,7 @@ import { TangleBlueprintsManagementFacet } from "../src/facets/tangle/TangleBlue import { TangleOperatorsFacet } from "../src/facets/tangle/TangleOperatorsFacet.sol"; import { TangleServicesRequestsFacet } from "../src/facets/tangle/TangleServicesRequestsFacet.sol"; import { TangleServicesFacet } from "../src/facets/tangle/TangleServicesFacet.sol"; +import { TangleServicesViewsFacet } from "../src/facets/tangle/TangleServicesViewsFacet.sol"; import { TangleServicesLifecycleFacet } from "../src/facets/tangle/TangleServicesLifecycleFacet.sol"; import { TangleJobsFacet } from "../src/facets/tangle/TangleJobsFacet.sol"; import { TangleJobsAggregationFacet } from "../src/facets/tangle/TangleJobsAggregationFacet.sol"; @@ -25,6 +26,8 @@ import { TangleJobsRFQFacet } from "../src/facets/tangle/TangleJobsRFQFacet.sol" import { TangleQuotesFacet } from "../src/facets/tangle/TangleQuotesFacet.sol"; import { TangleQuotesExtensionFacet } from "../src/facets/tangle/TangleQuotesExtensionFacet.sol"; import { TanglePaymentsFacet } from "../src/facets/tangle/TanglePaymentsFacet.sol"; +import { TanglePaymentsRewardsFacet } from "../src/facets/tangle/TanglePaymentsRewardsFacet.sol"; +import { TanglePaymentsDistributionFacet } from "../src/facets/tangle/TanglePaymentsDistributionFacet.sol"; import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.sol"; import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; @@ -398,6 +401,7 @@ contract DeployV2 is DeployScriptBase { router.registerFacet(address(new TangleOperatorsFacet())); router.registerFacet(address(new TangleServicesRequestsFacet())); router.registerFacet(address(new TangleServicesFacet())); + router.registerFacet(address(new TangleServicesViewsFacet())); router.registerFacet(address(new TangleServicesLifecycleFacet())); router.registerFacet(address(new TangleJobsFacet())); router.registerFacet(address(new TangleJobsAggregationFacet())); @@ -405,6 +409,8 @@ contract DeployV2 is DeployScriptBase { router.registerFacet(address(new TangleQuotesFacet())); router.registerFacet(address(new TangleQuotesExtensionFacet())); router.registerFacet(address(new TanglePaymentsFacet())); + router.registerFacet(address(new TanglePaymentsRewardsFacet())); + router.registerFacet(address(new TanglePaymentsDistributionFacet())); router.registerFacet(address(new TangleSlashingFacet())); } diff --git a/script/DeployContractsOnly.s.sol b/script/DeployContractsOnly.s.sol index a1212cf7..34940cfa 100644 --- a/script/DeployContractsOnly.s.sol +++ b/script/DeployContractsOnly.s.sol @@ -16,6 +16,7 @@ import { TangleBlueprintsManagementFacet } from "../src/facets/tangle/TangleBlue import { TangleOperatorsFacet } from "../src/facets/tangle/TangleOperatorsFacet.sol"; import { TangleServicesRequestsFacet } from "../src/facets/tangle/TangleServicesRequestsFacet.sol"; import { TangleServicesFacet } from "../src/facets/tangle/TangleServicesFacet.sol"; +import { TangleServicesViewsFacet } from "../src/facets/tangle/TangleServicesViewsFacet.sol"; import { TangleServicesLifecycleFacet } from "../src/facets/tangle/TangleServicesLifecycleFacet.sol"; import { TangleJobsFacet } from "../src/facets/tangle/TangleJobsFacet.sol"; import { TangleJobsAggregationFacet } from "../src/facets/tangle/TangleJobsAggregationFacet.sol"; @@ -23,6 +24,8 @@ import { TangleJobsRFQFacet } from "../src/facets/tangle/TangleJobsRFQFacet.sol" import { TangleQuotesFacet } from "../src/facets/tangle/TangleQuotesFacet.sol"; import { TangleQuotesExtensionFacet } from "../src/facets/tangle/TangleQuotesExtensionFacet.sol"; import { TanglePaymentsFacet } from "../src/facets/tangle/TanglePaymentsFacet.sol"; +import { TanglePaymentsRewardsFacet } from "../src/facets/tangle/TanglePaymentsRewardsFacet.sol"; +import { TanglePaymentsDistributionFacet } from "../src/facets/tangle/TanglePaymentsDistributionFacet.sol"; import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.sol"; import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; @@ -127,6 +130,7 @@ contract DeployContractsOnly is Script { tangle.registerFacet(address(new TangleOperatorsFacet())); tangle.registerFacet(address(new TangleServicesRequestsFacet())); tangle.registerFacet(address(new TangleServicesFacet())); + tangle.registerFacet(address(new TangleServicesViewsFacet())); tangle.registerFacet(address(new TangleServicesLifecycleFacet())); tangle.registerFacet(address(new TangleJobsFacet())); tangle.registerFacet(address(new TangleJobsAggregationFacet())); @@ -134,6 +138,8 @@ contract DeployContractsOnly is Script { tangle.registerFacet(address(new TangleQuotesFacet())); tangle.registerFacet(address(new TangleQuotesExtensionFacet())); tangle.registerFacet(address(new TanglePaymentsFacet())); + tangle.registerFacet(address(new TanglePaymentsRewardsFacet())); + tangle.registerFacet(address(new TanglePaymentsDistributionFacet())); tangle.registerFacet(address(new TangleSlashingFacet())); } } diff --git a/script/LocalTestnet.s.sol b/script/LocalTestnet.s.sol index 5f58561f..3d4b760c 100644 --- a/script/LocalTestnet.s.sol +++ b/script/LocalTestnet.s.sol @@ -30,6 +30,7 @@ import { TangleBlueprintsManagementFacet } from "../src/facets/tangle/TangleBlue import { TangleOperatorsFacet } from "../src/facets/tangle/TangleOperatorsFacet.sol"; import { TangleServicesRequestsFacet } from "../src/facets/tangle/TangleServicesRequestsFacet.sol"; import { TangleServicesFacet } from "../src/facets/tangle/TangleServicesFacet.sol"; +import { TangleServicesViewsFacet } from "../src/facets/tangle/TangleServicesViewsFacet.sol"; import { TangleServicesLifecycleFacet } from "../src/facets/tangle/TangleServicesLifecycleFacet.sol"; import { TangleJobsFacet } from "../src/facets/tangle/TangleJobsFacet.sol"; import { TangleJobsAggregationFacet } from "../src/facets/tangle/TangleJobsAggregationFacet.sol"; @@ -37,6 +38,8 @@ import { TangleJobsRFQFacet } from "../src/facets/tangle/TangleJobsRFQFacet.sol" import { TangleQuotesFacet } from "../src/facets/tangle/TangleQuotesFacet.sol"; import { TangleQuotesExtensionFacet } from "../src/facets/tangle/TangleQuotesExtensionFacet.sol"; import { TanglePaymentsFacet } from "../src/facets/tangle/TanglePaymentsFacet.sol"; +import { TanglePaymentsRewardsFacet } from "../src/facets/tangle/TanglePaymentsRewardsFacet.sol"; +import { TanglePaymentsDistributionFacet } from "../src/facets/tangle/TanglePaymentsDistributionFacet.sol"; import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.sol"; import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; @@ -1326,6 +1329,7 @@ contract LocalTestnetSetup is Script, BlueprintDefinitionHelper { router.registerFacet(address(new TangleOperatorsFacet())); router.registerFacet(address(new TangleServicesRequestsFacet())); router.registerFacet(address(new TangleServicesFacet())); + router.registerFacet(address(new TangleServicesViewsFacet())); router.registerFacet(address(new TangleServicesLifecycleFacet())); router.registerFacet(address(new TangleJobsFacet())); router.registerFacet(address(new TangleJobsAggregationFacet())); @@ -1333,6 +1337,8 @@ contract LocalTestnetSetup is Script, BlueprintDefinitionHelper { router.registerFacet(address(new TangleQuotesFacet())); router.registerFacet(address(new TangleQuotesExtensionFacet())); router.registerFacet(address(new TanglePaymentsFacet())); + router.registerFacet(address(new TanglePaymentsRewardsFacet())); + router.registerFacet(address(new TanglePaymentsDistributionFacet())); router.registerFacet(address(new TangleSlashingFacet())); } diff --git a/src/core/Payments.sol b/src/core/Payments.sol deleted file mode 100644 index 59e2eaa6..00000000 --- a/src/core/Payments.sol +++ /dev/null @@ -1,1104 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.26; - -import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; - -import { Base } from "./Base.sol"; -import { PaymentsEffectiveExposure } from "./PaymentsEffectiveExposure.sol"; -import { Types } from "../libraries/Types.sol"; -import { Errors } from "../libraries/Errors.sol"; -import { PaymentLib } from "../libraries/PaymentLib.sol"; -import { IBlueprintServiceManager } from "../interfaces/IBlueprintServiceManager.sol"; -import { IServiceFeeDistributor } from "../interfaces/IServiceFeeDistributor.sol"; -import { IStaking } from "../interfaces/IStaking.sol"; -import { IPriceOracle } from "../oracles/interfaces/IPriceOracle.sol"; - -/// @title Payments -/// @notice Payment distribution, escrow, and rewards -/// @dev TIMESTAMP ASSUMPTIONS: -/// - block.timestamp is used for subscription billing intervals and TTL checks -/// - Miners can manipulate timestamps by ~15 seconds on Ethereum -/// - This tolerance is acceptable for billing intervals (typically hours/days) -/// - For critical time-sensitive operations, consider using block numbers instead -/// - TTL expiry and subscription intervals use timestamps for user-friendliness -/// @dev PAYMENT DISTRIBUTION: -/// - Operator payments are proportional to effective exposure (delegation × exposureBps) -/// - This ensures operators are paid based on actual security capital at risk -/// - If price oracle is configured, cross-asset values are normalized to USD -abstract contract Payments is Base, PaymentsEffectiveExposure { - using EnumerableSet for EnumerableSet.AddressSet; - using PaymentLib for PaymentLib.ServiceEscrow; - - // ═══════════════════════════════════════════════════════════════════════════ - // EVENTS - // ═══════════════════════════════════════════════════════════════════════════ - - event EscrowFunded(uint64 indexed serviceId, address indexed token, uint256 amount); - event EscrowRefunded(uint64 indexed serviceId, address indexed owner, address indexed token, uint256 amount); - event SubscriptionBilled(uint64 indexed serviceId, uint256 amount, uint64 period); - /// @notice Emitted when a subscription's bill window elapses but no active operators - /// exist to bill against. The `lastPaymentAt` cursor advances by `period` to - /// keep the schedule on rails; the escrow is not touched. - event SubscriptionBillSkippedNoOperators(uint64 indexed serviceId, uint64 period); - /// @notice Emitted when the manager hook reduced the bill via `computeBillAdjustmentBps`. - /// @dev `preAdjustmentAmount` is the TWAP-and-cap-resolved amount (NOT the blueprint's - /// nominal rate). `adjustedAmount` is what the protocol ultimately drew from escrow. - event SubscriptionBillAdjustedByManager( - uint64 indexed serviceId, uint256 preAdjustmentAmount, uint256 adjustedAmount, uint16 adjustmentBps - ); - /// @notice Emitted when the bill caller's keeper rebate is added to their pending-rewards - /// mapping. The actual transfer happens on `claimRewards` — naming mirrors - /// `OperatorRewardAccrued` to avoid implying push-transfer at the event. - event KeeperRebateAccrued(uint64 indexed serviceId, address indexed keeper, address indexed token, uint256 amount); - /// @notice Emitted when the staker pool's share could not be routed (no distributor configured, - /// or the distributor reverted). The amount is refunded to the service escrow so the - /// customer can recover it, rather than being silently captured by the treasury. - event StakerShareRefundedToEscrow( - uint64 indexed serviceId, address indexed operator, address indexed token, uint256 amount, bytes reason - ); - /// @notice Emitted when a Subscription-pricing service has its per-operator TWAP cursors - /// and `subscriptionBaselineStake` seeded at activation. Indexers / off-chain - /// observers can subscribe here to track when the bill contract is locked in. - event SubscriptionBaselineInitialized(uint64 indexed serviceId, uint256 baselineStake, uint256 operatorCount); - event PaymentDistributed( - uint64 indexed serviceId, - uint64 indexed blueprintId, - address indexed token, - uint256 grossAmount, - address developerRecipient, - uint256 developerAmount, - uint256 protocolAmount, - uint256 operatorPoolAmount, - uint256 stakerPoolAmount - ); - event OperatorRewardAccrued( - uint64 indexed serviceId, address indexed operator, address indexed token, uint64 blueprintId, uint256 amount - ); - event RewardsClaimed(address indexed account, address indexed token, uint256 amount); - event TntPaymentDiscountApplied( - uint64 indexed serviceId, address indexed recipient, address indexed token, uint256 amount - ); - - // ═══════════════════════════════════════════════════════════════════════════ - // ESCROW MANAGEMENT - // ═══════════════════════════════════════════════════════════════════════════ - - /// @notice Fund a service's escrow. - /// @dev Re-checks (a) the service hasn't expired and (b) the blueprint manager still - /// whitelists the escrow's payment token. Without these checks a service could - /// be funded after expiry (escrow stuck) or after a manager policy revoke - /// (ongoing top-ups for a token the protocol now disallows). - function fundService(uint64 serviceId, uint256 amount) external payable whenNotPaused nonReentrant { - Types.Service storage svc = _getService(serviceId); - if (svc.status != Types.ServiceStatus.Active) { - revert Errors.ServiceNotActive(serviceId); - } - if (svc.pricing != Types.PricingModel.Subscription) { - revert Errors.InvalidState(); - } - if (svc.ttl > 0 && block.timestamp > svc.createdAt + svc.ttl) { - revert Errors.ServiceExpired(serviceId); - } - - PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; - address token = escrow.token; - - Types.Blueprint storage bp = _blueprints[svc.blueprintId]; - if (bp.manager != address(0) && !_isPaymentAssetAllowedByManager(bp.manager, serviceId, token)) { - revert Errors.TokenNotAllowed(token); - } - - PaymentLib.depositToEscrow(escrow, token, amount, msg.value); - - emit EscrowFunded(serviceId, token, amount); - _recordPayment(msg.sender, serviceId, token, amount); - } - - /// @notice Withdraw remaining escrow balance after service termination - function withdrawRemainingEscrow(uint64 serviceId) external nonReentrant { - Types.Service storage svc = _getService(serviceId); - if (svc.owner != msg.sender) { - revert Errors.NotServiceOwner(serviceId, msg.sender); - } - if (svc.status != Types.ServiceStatus.Terminated) { - revert Errors.ServiceNotTerminated(serviceId); - } - - PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; - uint256 remaining = escrow.balance; - if (remaining == 0) revert Errors.ZeroAmount(); - - address token = escrow.token; - escrow.balance = 0; - escrow.totalReleased += remaining; - - PaymentLib.transferPayment(svc.owner, token, remaining); - emit EscrowRefunded(serviceId, svc.owner, token, remaining); - } - - /// @notice Bill a subscription service - /// @dev Anyone can call this to trigger billing; no incentive for single billing - function billSubscription(uint64 serviceId) external whenNotPaused nonReentrant { - _billSubscriptionInternal(serviceId); - } - - /// @notice Batch bill multiple subscription services. - /// @dev Each service is billed independently in a try-bill mode that returns false - /// (rather than reverting) for any ineligible service. The caller earns the - /// keeper rebate on every successfully drawn bill — incentivising bots to - /// sweep the schedule. `totalBilled` reflects the actual amounts drawn (after - /// TWAP scaling + QoS adjustment), not the blueprint nominal rate. - function billSubscriptionBatch(uint64[] calldata serviceIds) - external - whenNotPaused - nonReentrant - returns (uint256 totalBilled, uint256 billedCount) - { - uint256 serviceIdsLength = serviceIds.length; - if (serviceIdsLength == 0) revert Errors.ZeroAmount(); - - for (uint256 i = 0; i < serviceIdsLength;) { - (bool billed, uint256 amount) = _tryBillSubscriptionMeasured(serviceIds[i]); - if (billed) { - totalBilled += amount; - if (amount > 0) billedCount++; - } - unchecked { - ++i; - } - } - } - - /// @notice Get services that are billable (past their billing interval) - /// @param serviceIds Array of service IDs to check - /// @return billable Array of service IDs that can be billed - function getBillableServices(uint64[] calldata serviceIds) external view returns (uint64[] memory billable) { - uint256 serviceIdsLength = serviceIds.length; - uint64[] memory temp = new uint64[](serviceIdsLength); - uint256 count = 0; - - for (uint256 i = 0; i < serviceIdsLength;) { - if (_isBillable(serviceIds[i])) { - temp[count++] = serviceIds[i]; - } - unchecked { - ++i; - } - } - - billable = new uint64[](count); - for (uint256 i = 0; i < count;) { - billable[i] = temp[i]; - unchecked { - ++i; - } - } - } - - /// @notice Permissionless subscription bill (reverts on failure). - function _billSubscriptionInternal(uint64 serviceId) internal { - (bool billed,) = _billSubscriptionImpl(serviceId, true, msg.sender); - if (!billed) revert Errors.InvalidState(); // unreachable under revertOnFail=true - } - - /// @notice Permissionless subscription bill (returns false on failure instead of reverting). - function _tryBillSubscription(uint64 serviceId) internal returns (bool) { - (bool billed,) = _billSubscriptionImpl(serviceId, false, msg.sender); - return billed; - } - - /// @notice Try-bill variant that also returns the actual drawn amount so callers - /// (notably `billSubscriptionBatch`) can report a true revenue figure. - function _tryBillSubscriptionMeasured(uint64 serviceId) internal returns (bool, uint256) { - return _billSubscriptionImpl(serviceId, false, msg.sender); - } - - /// @notice Core subscription billing implementation, shared between strict and try-bill paths. - /// @dev One call processes exactly one period of length `interval`, advancing `lastPaymentAt` - /// by `interval` (not to `block.timestamp`), so missed periods catch up over repeated calls. - /// Behavior: - /// 1. Pre-checks: service active, subscription pricing, not TTL-expired, period due. - /// 2. Active-operator snapshot. If empty: advance cursor without billing — escrow - /// untouched, customer keeps funds, schedule stays on rails. - /// 3. Compute per-operator cum-stake-second deltas. Forward-project to `periodEnd` - /// on late bills so the next bill picks up cleanly from the period boundary. - /// 4. Apply manager QoS adjustment (best-effort; clamped to [0, 10_000]). - /// 5. Release `amount` from escrow. - /// 6. Distribute by per-operator TWAP weight (cumDelta × exposureBps). Pay keeper - /// rebate to the caller from the operator pool's keeper slice. - /// @param serviceId Service to bill - /// @param revertOnFail When true, eligibility checks revert; when false, return false silently. - /// @param keeper Caller of the public bill entry point — receives the keeper rebate. - /// @return billed True if a bill was drawn (or the period was skipped due to zero operators). - function _billSubscriptionImpl(uint64 serviceId, bool revertOnFail, address keeper) - internal - returns (bool billed, uint256 amountDrawn) - { - Types.Service storage svc = _services[serviceId]; - - // Eligibility checks. - if (svc.status != Types.ServiceStatus.Active) { - if (revertOnFail) revert Errors.ServiceNotActive(serviceId); - return (false, 0); - } - if (svc.pricing != Types.PricingModel.Subscription) { - if (revertOnFail) revert Errors.InvalidState(); - return (false, 0); - } - if (svc.ttl > 0 && block.timestamp > svc.createdAt + svc.ttl) { - if (revertOnFail) revert Errors.ServiceExpired(serviceId); - return (false, 0); - } - - Types.BlueprintConfig storage bpConfig = _blueprintConfigs[svc.blueprintId]; - uint64 interval = bpConfig.subscriptionInterval; - uint256 nominalRate = bpConfig.subscriptionRate; - uint64 periodStart = svc.lastPaymentAt; - uint64 periodEnd = periodStart + interval; - - if (block.timestamp < periodEnd) { - if (revertOnFail) revert Errors.DeadlineExpired(); - return (false, 0); - } - - address[] memory operators = _activeServiceOperators(serviceId); - - // Zero-operator path: advance the cursor and skip the bill so the schedule does - // not livelock. The customer's escrow is untouched; if operators rejoin, the - // next bill picks up from `periodEnd` and bills the standard rate. - if (operators.length == 0) { - svc.lastPaymentAt = periodEnd; - emit SubscriptionBillSkippedNoOperators(serviceId, interval); - return (true, 0); - } - - // Same weights drive bill amount AND payout split. `_accrueOperatorWeights` is - // a VIEW that computes projected cursors without writing them; cursors are - // committed only on a successful bill or period skip, so a failed try-bill - // does not advance state and cannot be used to consume periods for free. - BillWeights memory w = _accrueOperatorWeights(serviceId, operators, periodEnd); - - PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; - - // Baseline must have been pinned at activation; a zero here means a service was - // activated via a non-canonical path that skipped baseline seeding. Reverting - // loudly prevents a stake-ramp attacker from front-running baseline at first bill. - if (escrow.subscriptionBaselineStake == 0) { - revert Errors.SubscriptionBaselineNotInitialized(serviceId); - } - uint256 amount = PaymentLib.twapBillAmount( - nominalRate, w.cumDeltaPeriod, escrow.subscriptionBaselineStake, uint256(interval) - ); - - // Bound the bill at the nominal rate: operators ramping stake cannot inflate - // the customer's bill, but ramping-down still reduces it. Per-op weights stay - // uncapped so ramping operators earn a larger slice of the same (capped) pool. - // Cap also keeps `terminateServiceForNonPayment`'s `balance < rate` eligibility - // consistent with the bill's `balance >= amount` requirement. - if (amount > nominalRate) amount = nominalRate; - - // Manager QoS hook can discount (never inflate) the bill. Hook failures / - // out-of-range returns fall back to the cap-resolved amount. - uint16 qosBps = _resolveBillAdjustmentBps(svc.blueprintId, serviceId, periodStart, periodEnd); - if (qosBps < BPS_DENOMINATOR) { - uint256 adjusted = PaymentLib.applyQosAdjustment(amount, qosBps); - emit SubscriptionBillAdjustedByManager(serviceId, amount, adjusted, qosBps); - amount = adjusted; - } - - // Insufficient escrow: do NOT commit cursors and do NOT advance `lastPaymentAt`. - // The period stays due so `terminateServiceForNonPayment` remains the canonical - // recovery path and a future top-up + retry processes the same window. - if (amount > 0 && escrow.balance < amount) { - if (revertOnFail) revert Errors.InsufficientEscrowBalance(amount, escrow.balance); - return (false, 0); - } - - // From here every exit path commits the cursors and advances `lastPaymentAt`. - // Cursor SSTOREs land BEFORE any external transfer in `_distributeBill` so a - // reverting transfer never leaves cursors stale. - _commitOperatorCursors(serviceId, operators, w.projectedByOpAsset); - svc.lastPaymentAt = periodEnd; - - // Skip-on-dust: a bill that rounds to less than 1 wei per recipient is treated - // as a zero-cost processed period rather than reverting in `_distributeBill`. - if (amount > 0 && amount < PaymentLib.minBillAmount(_paymentSplit, operators.length)) { - emit SubscriptionBilled(serviceId, 0, interval); - return (true, 0); - } - - if (amount == 0) { - emit SubscriptionBilled(serviceId, 0, interval); - return (true, 0); - } - - address token = PaymentLib.releaseFromEscrow(escrow, amount); - _distributeBill( - BillDistribution({ - serviceId: serviceId, - blueprintId: svc.blueprintId, - token: token, - amount: amount, - operators: operators, - weights: w.weights, - totalWeight: w.totalWeight, - hasSecurityCommitments: w.hasSecurityCommitments, - keeper: keeper - }) - ); - - emit SubscriptionBilled(serviceId, amount, interval); - return (true, amount); - } - - /// @notice Persist the projected TWAP cursors after a bill has passed all guard checks. - /// @dev Split out from `_accrueOperatorWeights` so a failed try-bill (insufficient escrow) - /// cannot advance cursors, which would let a service owner consume the period for - /// free on a subsequent retry by zeroing out cumDelta. - function _commitOperatorCursors( - uint64 serviceId, - address[] memory operators, - uint256[][] memory projectedByOpAsset - ) - internal - { - Types.Asset memory bondAsset = _bondAssetForBilling(); - for (uint256 i = 0; i < operators.length;) { - address op = operators[i]; - Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; - uint256[] memory projected = projectedByOpAsset[i]; - uint256 m = commitments.length; - if (m == 0) { - // Fallback path: single bond-asset cursor. `projected` was sized to 1 by - // `_accrueOperatorWeights` to mirror this shape. - bytes32 assetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); - _twapCursorByOpAsset[serviceId][op][assetHash] = projected[0]; - } else { - for (uint256 j = 0; j < m;) { - bytes32 assetHash = keccak256(abi.encode(commitments[j].asset.kind, commitments[j].asset.token)); - _twapCursorByOpAsset[serviceId][op][assetHash] = projected[j]; - unchecked { - ++j; - } - } - } - unchecked { - ++i; - } - } - } - - /// @notice Initialize per-(operator, asset) TWAP cursors and pin the multi-asset baseline. - /// @dev Walks each operator's `AssetSecurityCommitment[]` and seeds cursors for every - /// (op, asset) pair. Baseline is the exposure-weighted aggregate - /// `Σ_op Σ_asset (delegation × commitmentBps)`, USD-normalized when a price oracle - /// is configured. Pinned once at activation; subsequent bills measure against this - /// snapshot so an operator cannot inflate the customer's bill by ramping stake on - /// a single asset post-activation. - function _initSubscriptionBaseline(uint64 serviceId, address[] calldata operators) internal { - IStaking staking = _getStaking(); - address oracleAddr = _getPriceOracle(); - bool useOracle = oracleAddr != address(0); - IPriceOracle oracle = IPriceOracle(oracleAddr); - Types.Asset memory bondAsset = _bondAssetForBilling(); - - uint256 baseline; - uint256 n = operators.length; - for (uint256 i = 0; i < n;) { - address op = operators[i]; - Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; - uint256 m = commitments.length; - if (m == 0) { - // No per-asset commitments specified — fall back to the bond asset at - // the operator's `ServiceOperator.exposureBps`. Mirrors the legacy - // single-asset semantics for services that don't opt into the - // multi-asset commitment system. - bytes32 assetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); - (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, bondAsset); - _twapCursorByOpAsset[serviceId][op][assetHash] = cumOp == 0 ? 1 : cumOp; - uint16 fallbackBps = _serviceOperators[serviceId][op].exposureBps; - if (fallbackBps == 0) fallbackBps = uint16(BPS_DENOMINATOR); - uint256 exposedAmount = (stakeOp * uint256(fallbackBps)) / BPS_DENOMINATOR; - if (useOracle && exposedAmount > 0) { - address token = bondAsset.kind == Types.AssetKind.Native ? address(0) : bondAsset.token; - baseline += oracle.toUSD(token, exposedAmount); - } else { - baseline += exposedAmount; - } - } else { - for (uint256 j = 0; j < m;) { - Types.AssetSecurityCommitment storage c = commitments[j]; - bytes32 assetHash = keccak256(abi.encode(c.asset.kind, c.asset.token)); - (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, c.asset); - _twapCursorByOpAsset[serviceId][op][assetHash] = cumOp == 0 ? 1 : cumOp; - - uint256 exposedAmount = (stakeOp * uint256(c.exposureBps)) / BPS_DENOMINATOR; - if (useOracle && exposedAmount > 0) { - address token = c.asset.kind == Types.AssetKind.Native ? address(0) : c.asset.token; - baseline += oracle.toUSD(token, exposedAmount); - } else { - baseline += exposedAmount; - } - unchecked { - ++j; - } - } - } - unchecked { - ++i; - } - } - // Pathological zero-stake activation: defensive minimum of 1 keeps the denominator - // positive. In practice activation requires staked, committed operators. - uint256 pinned = baseline == 0 ? 1 : baseline; - _serviceEscrows[serviceId].subscriptionBaselineStake = pinned; - emit SubscriptionBaselineInitialized(serviceId, pinned, n); - } - - /// @notice Per-bill state computed from per-(operator, asset) stake-seconds. - /// @dev `projectedByOpAsset[i]` is a jagged inner array indexed by the operator's - /// `AssetSecurityCommitment[j]`. Cursors are committed only after the bill passes - /// the escrow-balance check so a failed try-bill cannot advance state. - struct BillWeights { - uint256 cumDeltaPeriod; - uint256[] weights; // per-operator (exposure-weighted across assets) - uint256[][] projectedByOpAsset; // per-(operator, asset_in_commitments) - uint256 totalWeight; - bool hasSecurityCommitments; - } - - function _accrueOperatorWeights( - uint64 serviceId, - address[] memory operators, - uint64 periodEnd - ) - internal - view - returns (BillWeights memory result) - { - IStaking staking = _getStaking(); - address oracleAddr = _getPriceOracle(); - bool useOracle = oracleAddr != address(0); - IPriceOracle oracle = IPriceOracle(oracleAddr); - Types.Asset memory bondAsset = _bondAssetForBilling(); - uint256 tailSeconds = block.timestamp > periodEnd ? block.timestamp - uint256(periodEnd) : 0; - - uint256 n = operators.length; - result.weights = new uint256[](n); - result.projectedByOpAsset = new uint256[][](n); - - for (uint256 i = 0; i < n;) { - address op = operators[i]; - Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; - uint256 m = commitments.length; - uint256 opWeight; - uint256[] memory projected; - if (m == 0) { - // Fallback: no per-asset commitments → treat as a single implicit - // commitment to the bond asset at the operator's overall - // `ServiceOperator.exposureBps`. Mirrors `_initSubscriptionBaseline`. - projected = new uint256[](1); - bytes32 assetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); - (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, bondAsset); - uint256 projectedCum = _projectToPeriodEnd(cumOp, stakeOp, tailSeconds); - uint256 cursor = _twapCursorByOpAsset[serviceId][op][assetHash]; - uint256 opDeltaRaw; - if (cursor != 0 && projectedCum > cursor) { - opDeltaRaw = projectedCum - cursor; - } - projected[0] = projectedCum; - - uint16 fallbackBps = _serviceOperators[serviceId][op].exposureBps; - if (fallbackBps == 0) fallbackBps = uint16(BPS_DENOMINATOR); - uint256 contribution = (opDeltaRaw * uint256(fallbackBps)) / BPS_DENOMINATOR; - if (useOracle && contribution > 0) { - address token = bondAsset.kind == Types.AssetKind.Native ? address(0) : bondAsset.token; - contribution = oracle.toUSD(token, contribution); - } - opWeight = contribution; - } else { - projected = new uint256[](m); - for (uint256 j = 0; j < m;) { - Types.AssetSecurityCommitment storage c = commitments[j]; - bytes32 assetHash = keccak256(abi.encode(c.asset.kind, c.asset.token)); - (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, c.asset); - uint256 projectedCum = _projectToPeriodEnd(cumOp, stakeOp, tailSeconds); - uint256 cursor = _twapCursorByOpAsset[serviceId][op][assetHash]; - uint256 opDeltaRaw; - if (cursor != 0 && projectedCum > cursor) { - opDeltaRaw = projectedCum - cursor; - } - projected[j] = projectedCum; - - // Exposure-weighted contribution. With oracle: USD-normalized so - // heterogeneous assets aggregate by value. Without oracle: raw - // token-second amounts — comparable only when all committed assets - // share a unit, but proportional within that unit. - uint256 contribution = (opDeltaRaw * uint256(c.exposureBps)) / BPS_DENOMINATOR; - if (useOracle && contribution > 0) { - address token = c.asset.kind == Types.AssetKind.Native ? address(0) : c.asset.token; - contribution = oracle.toUSD(token, contribution); - } - opWeight += contribution; - unchecked { - ++j; - } - } - } - result.projectedByOpAsset[i] = projected; - result.weights[i] = opWeight; - result.totalWeight += opWeight; - result.cumDeltaPeriod += opWeight; - unchecked { - ++i; - } - } - - // Fallback weighting: when cumDelta is zero across the board (e.g. genuine zero-stake - // edge cases) OR every operator has zero exposureBps, distribute the operator pool - // equally across active operators so the bill — if any — still reaches them. - if (result.totalWeight == 0 && operators.length > 0) { - for (uint256 i = 0; i < operators.length;) { - result.weights[i] = 1; - unchecked { - ++i; - } - } - result.totalWeight = operators.length; - } - - // `hasSecurityCommitments` controls whether the staker pool is routed to the - // ServiceFeeDistributor or folded into the operator pool — real delegated stake. - (, uint256 totalExposure) = _calculateEffectiveExposures(serviceId, operators); - result.hasSecurityCommitments = totalExposure > 0; - } - - /// @notice Resolve the developer payment recipient via a gas-capped manager hook. - /// @dev Same defense-in-depth as `_resolveBillAdjustmentBps`: bounded gas, raw - /// staticcall, fall back to `blueprintOwner` on revert / empty / zero. Without - /// this cap a malicious manager could grief subscription bills (and any other - /// distribution path that pays a developer). - function _resolveDeveloperPaymentAddress( - address manager, - address blueprintOwner, - uint64 serviceId - ) - internal - view - returns (address) - { - if (manager == address(0)) return blueprintOwner; - (bool ok, bytes memory ret) = manager.staticcall{ gas: MANAGER_HOOK_GAS_LIMIT }( - abi.encodeWithSelector(IBlueprintServiceManager.queryDeveloperPaymentAddress.selector, serviceId) - ); - if (!ok || ret.length < 32) return blueprintOwner; - address dev = abi.decode(ret, (address)); - return dev == address(0) ? blueprintOwner : dev; - } - - /// @notice Resolve the per-period bill adjustment from the blueprint's manager hook. - /// @dev Best-effort with a hard gas cap (`MANAGER_HOOK_GAS_LIMIT`). Any revert / - /// out-of-range return / zero manager yields a full-bill (10_000 bps) result. - /// Values above 10_000 are clamped — a misbehaving manager cannot inflate a - /// customer's bill, only discount it. The gas cap prevents a malicious - /// manager from looping out the keeper's gas budget to make permissionless - /// bill triggers unprofitable. - function _resolveBillAdjustmentBps( - uint64 blueprintId, - uint64 serviceId, - uint64 periodStart, - uint64 periodEnd - ) - internal - view - returns (uint16) - { - address manager = _blueprints[blueprintId].manager; - if (manager == address(0)) return uint16(BPS_DENOMINATOR); - (bool ok, bytes memory ret) = manager.staticcall{ gas: MANAGER_HOOK_GAS_LIMIT }( - abi.encodeWithSelector(IBlueprintServiceManager.computeBillAdjustmentBps.selector, serviceId, periodStart, periodEnd) - ); - if (!ok || ret.length < 32) return uint16(BPS_DENOMINATOR); - uint256 bps = abi.decode(ret, (uint256)); - if (bps >= BPS_DENOMINATOR) return uint16(BPS_DENOMINATOR); - return uint16(bps); - } - - /// @notice Forward-project an operator's cum stake-seconds to the period boundary. - /// @dev `tailSeconds` is `block.timestamp - periodEnd` when the bill is late, or - /// zero when on-time. Exact when stake has been stable since `periodEnd`; - /// conservatively under-attributes when stake ramped down in the tail. - function _projectToPeriodEnd(uint256 cumNow, uint256 stakeNow, uint256 tailSeconds) - internal - pure - returns (uint256) - { - if (tailSeconds == 0) return cumNow; - uint256 tail = stakeNow * tailSeconds; - return cumNow > tail ? cumNow - tail : 0; - } - - /// @notice Predicate for `getBillableServices` — mirrors `_billSubscriptionImpl`'s - /// pre-conditions so off-chain keepers don't burn gas attempting bills the - /// impl will reject. - /// @dev Returns true when the service is active, subscription-priced, baseline-seeded, - /// past its TTL guard, past its billing interval, AND the escrow can cover at - /// least the nominal rate (the cap-at-nominal guarantee means a bill never - /// exceeds `subscriptionRate`, so `balance >= rate` is sufficient). - function _isBillable(uint64 serviceId) internal view returns (bool) { - Types.Service storage svc = _services[serviceId]; - if (svc.status != Types.ServiceStatus.Active) return false; - if (svc.pricing != Types.PricingModel.Subscription) return false; - if (svc.ttl > 0 && block.timestamp > svc.createdAt + svc.ttl) return false; - - Types.BlueprintConfig storage bpConfig = _blueprintConfigs[svc.blueprintId]; - if (block.timestamp < svc.lastPaymentAt + bpConfig.subscriptionInterval) return false; - - PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; - if (escrow.subscriptionBaselineStake == 0) return false; - // Cap-at-nominal means a successful bill never exceeds `subscriptionRate`. - if (escrow.balance < bpConfig.subscriptionRate) return false; - - return true; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // REWARDS - // ═══════════════════════════════════════════════════════════════════════════ - - /// @notice Claim pending rewards (native token) - function claimRewards() external nonReentrant { - _claimRewardsToken(msg.sender, address(0), false); - } - - /// @notice Claim pending rewards for specific token - function claimRewards(address token) external nonReentrant { - _claimRewardsToken(msg.sender, token, false); - } - - /// @notice Claim pending rewards for multiple tokens - function claimRewardsBatch(address[] calldata tokens) external nonReentrant { - uint256 tokensLength = tokens.length; - for (uint256 i = 0; i < tokensLength;) { - _claimRewardsToken(msg.sender, tokens[i], false); - unchecked { - ++i; - } - } - } - - /// @notice Claim pending rewards for all tokens tracked for the caller - function claimRewardsAll() external nonReentrant { - EnumerableSet.AddressSet storage set = _pendingRewardTokens[msg.sender]; - while (set.length() > 0) { - address token = set.at(set.length() - 1); - _claimRewardsToken(msg.sender, token, true); - } - } - - /// @notice Get pending rewards - function pendingRewards(address account) external view returns (uint256) { - return _pendingRewards[account][address(0)]; - } - - /// @notice Get pending rewards for token - function pendingRewards(address account, address token) external view returns (uint256) { - return _pendingRewards[account][token]; - } - - /// @notice Return the set of tokens with non-zero pending operator rewards for an account - function rewardTokens(address account) external view returns (address[] memory tokens) { - EnumerableSet.AddressSet storage set = _pendingRewardTokens[account]; - uint256 setLength = set.length(); - tokens = new address[](setLength); - for (uint256 i = 0; i < setLength;) { - tokens[i] = set.at(i); - unchecked { - ++i; - } - } - } - - function _claimRewardsToken(address account, address token, bool forceRemove) private { - uint256 claimed = PaymentLib.claimPendingReward(_pendingRewards, account, token); - if (claimed > 0) { - _pendingRewardTokens[account].remove(token); - emit RewardsClaimed(account, token, claimed); - } else if (forceRemove) { - _pendingRewardTokens[account].remove(token); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // ADMIN - // ═══════════════════════════════════════════════════════════════════════════ - - /// @notice Set payment split - /// @param split The new payment split configuration - function setPaymentSplit(Types.PaymentSplit calldata split) external onlyRole(ADMIN_ROLE) { - PaymentLib.validateSplit(split); - _paymentSplit = split; - emit PaymentSplitUpdated( - split.developerBps, split.protocolBps, split.operatorBps, split.stakerBps, split.keeperBps - ); - } - - /// @notice Set treasury - /// @param treasury_ The new treasury address - function setTreasury(address payable treasury_) external onlyRole(ADMIN_ROLE) { - if (treasury_ == address(0)) revert Errors.ZeroAddress(); - _treasury = treasury_; - emit TreasuryUpdated(treasury_); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // VIEW - // ═══════════════════════════════════════════════════════════════════════════ - - function paymentSplit() external view returns (uint16, uint16, uint16, uint16, uint16) { - return ( - _paymentSplit.developerBps, - _paymentSplit.protocolBps, - _paymentSplit.operatorBps, - _paymentSplit.stakerBps, - _paymentSplit.keeperBps - ); - } - - function treasury() external view returns (address payable) { - return _treasury; - } - - function getServiceEscrow(uint64 serviceId) external view returns (PaymentLib.ServiceEscrow memory) { - return _serviceEscrows[serviceId]; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // INTERNAL - // ═══════════════════════════════════════════════════════════════════════════ - - /// @notice Deposit to escrow - function _depositToEscrow(uint64 serviceId, address token, uint256 amount) internal { - PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; - escrow.token = token; - escrow.balance += amount; - escrow.totalDeposited += amount; - emit EscrowFunded(serviceId, token, amount); - } - - /// @notice Distribution parameters bundle. Keeps the wide signature manageable. - struct BillDistribution { - uint64 serviceId; - uint64 blueprintId; - address token; - uint256 amount; - address[] operators; - uint256[] weights; - uint256 totalWeight; - bool hasSecurityCommitments; - address keeper; // address(0) → no keeper rebate; share folds into operator pool - } - - /// @notice Distribute a bill to (developer, protocol, operator pool, staker pool, keeper). - /// @dev Single distribution path shared between subscription bills (keeper present, TWAP - /// weights) and non-subscription payments (no keeper, exposure-based weights). The - /// caller is responsible for computing weights — this function does not assume any - /// particular fairness model for the operator pool. - function _distributeBill(BillDistribution memory d) internal { - if (d.amount == 0) return; - if (d.operators.length == 0) revert Errors.NoOperators(); - - bool includeKeeper = d.keeper != address(0); - if (d.totalWeight == 0) revert Errors.InvalidState(); - PaymentLib.PaymentAmounts memory amounts = PaymentLib.calculateSplit(d.amount, _paymentSplit, includeKeeper); - - // Developer payment (manager can override the destination). - Types.Blueprint storage bp = _blueprints[d.blueprintId]; - Types.Service storage svc = _services[d.serviceId]; - address developerAddr = _resolveDeveloperPaymentAddress(bp.manager, bp.owner, d.serviceId); - PaymentLib.transferPayment(developerAddr, d.token, amounts.developerAmount); - - // TNT payment discount: funded from the protocol share, paid to the service owner. - if ( - d.token != address(0) && d.token == _tntToken && _tntPaymentDiscountBps > 0 && amounts.protocolAmount > 0 - && svc.owner != address(0) - ) { - uint256 desired = (d.amount * _tntPaymentDiscountBps) / BPS_DENOMINATOR; - uint256 discount = desired > amounts.protocolAmount ? amounts.protocolAmount : desired; - if (discount > 0) { - amounts.protocolAmount -= discount; - PaymentLib.transferPayment(svc.owner, d.token, discount); - emit TntPaymentDiscountApplied(d.serviceId, svc.owner, d.token, discount); - } - } - - // Protocol payment. - PaymentLib.transferPayment(_treasury, d.token, amounts.protocolAmount); - - // Keeper rebate: pull-pattern via _pendingRewards so the keeper's gas budget for - // this transaction stays predictable and contract-keepers don't get force-fed ETH. - if (includeKeeper && amounts.keeperAmount > 0) { - PaymentLib.addPendingReward(_pendingRewards, d.keeper, d.token, amounts.keeperAmount); - _pendingRewardTokens[d.keeper].add(d.token); - emit KeeperRebateAccrued(d.serviceId, d.keeper, d.token, amounts.keeperAmount); - } - - // When no real delegated stake backs operators, fold the staker share into the - // operator pool so the customer still funds compute providers in full. - uint256 operatorPool = - d.hasSecurityCommitments ? amounts.operatorAmount : amounts.operatorAmount + amounts.stakerAmount; - uint256 stakerPool = d.hasSecurityCommitments ? amounts.stakerAmount : 0; - - emit PaymentDistributed( - d.serviceId, - d.blueprintId, - d.token, - d.amount, - developerAddr, - amounts.developerAmount, - amounts.protocolAmount, - operatorPool, - stakerPool - ); - - _payOperatorPoolByWeight(d, operatorPool, stakerPool); - } - - /// @notice Distribute the operator + staker pools across active operators by `weights`. - /// @dev `_distributeBill` ensures `weights.length == operators.length`, `totalWeight > 0`, - /// and any rounding dust accumulates on the LAST operator so Σshares == pool exactly. - function _payOperatorPoolByWeight( - BillDistribution memory d, - uint256 operatorPool, - uint256 stakerPool - ) - internal - { - uint256 n = d.operators.length; - uint256 operatorDistributed; - uint256 stakerDistributed; - - for (uint256 i = 0; i < n;) { - uint256 opShare; - uint256 stakerShare; - if (i == n - 1) { - opShare = operatorPool - operatorDistributed; - stakerShare = stakerPool - stakerDistributed; - } else { - opShare = (operatorPool * d.weights[i]) / d.totalWeight; - stakerShare = (stakerPool * d.weights[i]) / d.totalWeight; - operatorDistributed += opShare; - stakerDistributed += stakerShare; - } - - if (opShare > 0) { - PaymentLib.addPendingReward(_pendingRewards, d.operators[i], d.token, opShare); - _pendingRewardTokens[d.operators[i]].add(d.token); - emit OperatorRewardAccrued(d.serviceId, d.operators[i], d.token, d.blueprintId, opShare); - } - if (stakerShare > 0) { - _forwardStakerShare(d.serviceId, d.blueprintId, d.operators[i], d.token, stakerShare); - } - unchecked { - ++i; - } - } - } - - /// @notice Backwards-compatible entry point for non-subscription distributions - /// (one-shot, RFQ, per-job). Computes exposure-based weights internally and - /// routes through the shared `_distributeBill` core with no keeper rebate. - function _distributePaymentWithEffectiveExposure( - uint64 serviceId, - uint64 blueprintId, - address token, - uint256 amount, - address[] memory operators, - uint256[] memory effectiveExposures, - uint256 totalEffectiveExposure, - bool hasSecurityCommitments - ) - internal - { - // Fallback weighting: when nobody has effective exposure, distribute the operator - // pool equally. Materializing this as a uniform `weights` array keeps the shared - // core simple at a marginal gas cost. - uint256[] memory weights = effectiveExposures; - uint256 totalWeight = totalEffectiveExposure; - if (totalWeight == 0 && operators.length > 0) { - weights = new uint256[](operators.length); - for (uint256 i = 0; i < operators.length;) { - weights[i] = 1; - unchecked { - ++i; - } - } - totalWeight = operators.length; - } - - _distributeBill( - BillDistribution({ - serviceId: serviceId, - blueprintId: blueprintId, - token: token, - amount: amount, - operators: operators, - weights: weights, - totalWeight: totalWeight, - hasSecurityCommitments: hasSecurityCommitments, - keeper: address(0) - }) - ); - } - - /// @notice Route the staker pool's per-operator share through the fee distributor. - /// @dev When the distributor is unset OR reverts, the share is refunded to the service - /// escrow rather than silently captured by the treasury. That way: - /// - the customer recovers funds for unstaked / unrouted operator shares, - /// - a misbehaving distributor cannot brick all subscription bills, and - /// - off-chain observers can see exactly why the share didn't reach stakers. - function _forwardStakerShare( - uint64 serviceId, - uint64 blueprintId, - address operator, - address token, - uint256 amount - ) - private - { - if (amount == 0) return; - address distributor = _serviceFeeDistributor; - - if (distributor == address(0)) { - _refundStakerShareToEscrow(serviceId, operator, token, amount, bytes("no-distributor")); - return; - } - - // ERC20: transfer to the distributor first so the callee can pull state-free. - // Native: pass `value` directly. Either failure path refunds the customer. - if (token == address(0)) { - try IServiceFeeDistributor(distributor).distributeServiceFee{ value: amount }( - serviceId, blueprintId, operator, token, amount - ) { - return; - } catch (bytes memory reason) { - _refundStakerShareToEscrow(serviceId, operator, token, amount, reason); - return; - } - } - - PaymentLib.transferPayment(distributor, token, amount); - try IServiceFeeDistributor(distributor).distributeServiceFee(serviceId, blueprintId, operator, token, amount) { - return; - } catch (bytes memory reason) { - // The ERC20 has already left this contract — fee distributor holds it. We cannot - // unilaterally claw the tokens back, so we emit a clear marker for the customer - // and protocol to handle off-chain. The escrow is NOT credited because the funds - // are not in escrow's accounting bucket. This is rare (distributor is owned by - // the protocol) and explicitly surfaced so it never goes unnoticed. - emit StakerShareRefundedToEscrow(serviceId, operator, token, amount, reason); - } - } - - function _refundStakerShareToEscrow( - uint64 serviceId, - address operator, - address token, - uint256 amount, - bytes memory reason - ) - private - { - PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; - // Defensive: only refund into an escrow that holds the same token. - if (escrow.token != token) { - PaymentLib.transferPayment(_treasury, token, amount); - return; - } - escrow.balance += amount; - // The release back to escrow is a counter-release: lower lifetime-released so the - // accounting invariant `totalDeposited >= totalReleased + balance` holds. - if (escrow.totalReleased >= amount) { - escrow.totalReleased -= amount; - } else { - escrow.totalReleased = 0; - } - emit StakerShareRefundedToEscrow(serviceId, operator, token, amount, reason); - } - - /// @dev Returns only operators currently active in the service. Operators that left - /// remain in the EnumerableSet for historical accounting; we must not pay them. - function _activeServiceOperators(uint64 serviceId) internal view returns (address[] memory active) { - address[] memory all = _serviceOperatorSet[serviceId].values(); - uint256 activeCount; - for (uint256 i = 0; i < all.length; ++i) { - if (_serviceOperators[serviceId][all[i]].active) activeCount++; - } - active = new address[](activeCount); - uint256 j; - for (uint256 i = 0; i < all.length; ++i) { - if (_serviceOperators[serviceId][all[i]].active) { - active[j++] = all[i]; - } - } - } - - /// @notice Calculate effective exposures with fallback to stored exposureBps - /// @dev When operators have no security commitments (common case), falls back to - /// the exposureBps stored on their ServiceOperator record for proportional distribution. - /// @return effectiveExposures Per-operator exposure weights - /// @return totalEffectiveExposure Sum of all weights - /// @return hasSecurityCommitments True when real delegated stake backs the operators (stakers exist) - function _calculateEffectiveExposuresWithFallback( - uint64 serviceId, - address[] memory operators - ) - internal - view - returns (uint256[] memory effectiveExposures, uint256 totalEffectiveExposure, bool hasSecurityCommitments) - { - (effectiveExposures, totalEffectiveExposure) = _calculateEffectiveExposures(serviceId, operators); - - // If commitment-based calculation found real delegated stake, stakers are backing operators - hasSecurityCommitments = totalEffectiveExposure > 0; - - // Fallback: when no security commitments exist, use stored exposureBps as weights. - if (totalEffectiveExposure == 0 && operators.length > 0) { - uint16[] memory bps = new uint16[](operators.length); - for (uint256 i = 0; i < operators.length;) { - bps[i] = _serviceOperators[serviceId][operators[i]].exposureBps; - unchecked { - ++i; - } - } - (effectiveExposures, totalEffectiveExposure) = _calculateSimpleExposures(operators, bps); - } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // EFFECTIVE EXPOSURE INTERFACE IMPLEMENTATIONS - // ═══════════════════════════════════════════════════════════════════════════ - - /// @inheritdoc PaymentsEffectiveExposure - function _getStaking() internal view override returns (IStaking) { - return _staking; - } - - /// @inheritdoc PaymentsEffectiveExposure - function _getPriceOracle() internal view override returns (address) { - return _priceOracle; - } - - /// @inheritdoc PaymentsEffectiveExposure - function _getServiceSecurityCommitments( - uint64 serviceId, - address operator - ) - internal - view - override - returns (Types.AssetSecurityCommitment[] storage) - { - return _serviceSecurityCommitments[serviceId][operator]; - } -} diff --git a/src/core/PaymentsBilling.sol b/src/core/PaymentsBilling.sol new file mode 100644 index 00000000..35e3c9ab --- /dev/null +++ b/src/core/PaymentsBilling.sol @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { PaymentsCore } from "./PaymentsCore.sol"; +import { Types } from "../libraries/Types.sol"; +import { Errors } from "../libraries/Errors.sol"; +import { PaymentLib } from "../libraries/PaymentLib.sol"; +import { IBlueprintServiceManager } from "../interfaces/IBlueprintServiceManager.sol"; +import { IStaking } from "../interfaces/IStaking.sol"; +import { IPriceOracle } from "../oracles/interfaces/IPriceOracle.sol"; +import { ITanglePaymentsInternal } from "../interfaces/ITanglePaymentsInternal.sol"; + +/// @title PaymentsBilling +/// @notice Subscription billing entry points, TWAP weighting, and baseline initialization. +/// @dev Inherits `PaymentsEffectiveExposure` so the staker-backed predicate +/// (`_calculateEffectiveExposures`) is available for weighting fallbacks. +abstract contract PaymentsBilling is PaymentsCore { + /// @notice Bill a subscription service + /// @dev Anyone can call this to trigger billing; no incentive for single billing + function billSubscription(uint64 serviceId) external whenNotPaused nonReentrant { + _billSubscriptionInternal(serviceId); + } + + /// @notice Batch bill multiple subscription services. + /// @dev Each service is billed independently in a try-bill mode that returns false + /// (rather than reverting) for any ineligible service. The caller earns the + /// keeper rebate on every successfully drawn bill — incentivising bots to + /// sweep the schedule. `totalBilled` reflects the actual amounts drawn (after + /// TWAP scaling + QoS adjustment), not the blueprint nominal rate. + function billSubscriptionBatch(uint64[] calldata serviceIds) + external + whenNotPaused + nonReentrant + returns (uint256 totalBilled, uint256 billedCount) + { + uint256 serviceIdsLength = serviceIds.length; + if (serviceIdsLength == 0) revert Errors.ZeroAmount(); + + for (uint256 i = 0; i < serviceIdsLength;) { + (bool billed, uint256 amount) = _tryBillSubscriptionMeasured(serviceIds[i]); + if (billed) { + totalBilled += amount; + if (amount > 0) billedCount++; + } + unchecked { + ++i; + } + } + } + + /// @notice Permissionless subscription bill (reverts on failure). + function _billSubscriptionInternal(uint64 serviceId) internal { + (bool billed,) = _billSubscriptionImpl(serviceId, true, msg.sender); + if (!billed) revert Errors.InvalidState(); // unreachable under revertOnFail=true + } + + /// @notice Try-bill variant that also returns the actual drawn amount so callers + /// (notably `billSubscriptionBatch`) can report a true revenue figure. + function _tryBillSubscriptionMeasured(uint64 serviceId) internal returns (bool, uint256) { + return _billSubscriptionImpl(serviceId, false, msg.sender); + } + + /// @notice Core subscription billing implementation, shared between strict and try-bill paths. + /// @dev One call processes exactly one period of length `interval`, advancing `lastPaymentAt` + /// by `interval` (not to `block.timestamp`), so missed periods catch up over repeated calls. + /// Behavior: + /// 1. Pre-checks: service active, subscription pricing, not TTL-expired, period due. + /// 2. Active-operator snapshot. If empty: advance cursor without billing — escrow + /// untouched, customer keeps funds, schedule stays on rails. + /// 3. Compute per-operator cum-stake-second deltas. Forward-project to `periodEnd` + /// on late bills so the next bill picks up cleanly from the period boundary. + /// 4. Apply manager QoS adjustment (best-effort; clamped to [0, 10_000]). + /// 5. Release `amount` from escrow. + /// 6. Distribute by per-operator TWAP weight (cumDelta × exposureBps). Pay keeper + /// rebate to the caller from the operator pool's keeper slice. + /// @param serviceId Service to bill + /// @param revertOnFail When true, eligibility checks revert; when false, return false silently. + /// @param keeper Caller of the public bill entry point — receives the keeper rebate. + /// @return billed True if a bill was drawn (or the period was skipped due to zero operators). + function _billSubscriptionImpl(uint64 serviceId, bool revertOnFail, address keeper) + internal + returns (bool billed, uint256 amountDrawn) + { + Types.Service storage svc = _services[serviceId]; + + // Eligibility checks. + if (svc.status != Types.ServiceStatus.Active) { + if (revertOnFail) revert Errors.ServiceNotActive(serviceId); + return (false, 0); + } + if (svc.pricing != Types.PricingModel.Subscription) { + if (revertOnFail) revert Errors.InvalidState(); + return (false, 0); + } + if (svc.ttl > 0 && block.timestamp > svc.createdAt + svc.ttl) { + if (revertOnFail) revert Errors.ServiceExpired(serviceId); + return (false, 0); + } + + Types.BlueprintConfig storage bpConfig = _blueprintConfigs[svc.blueprintId]; + uint64 interval = bpConfig.subscriptionInterval; + uint256 nominalRate = bpConfig.subscriptionRate; + uint64 periodStart = svc.lastPaymentAt; + uint64 periodEnd = periodStart + interval; + + if (block.timestamp < periodEnd) { + if (revertOnFail) revert Errors.DeadlineExpired(); + return (false, 0); + } + + address[] memory operators = _activeServiceOperators(serviceId); + + // Zero-operator path: advance the cursor and skip the bill so the schedule does + // not livelock. The customer's escrow is untouched; if operators rejoin, the + // next bill picks up from `periodEnd` and bills the standard rate. + if (operators.length == 0) { + svc.lastPaymentAt = periodEnd; + emit SubscriptionBillSkippedNoOperators(serviceId, interval); + return (true, 0); + } + + // Same weights drive bill amount AND payout split. `_accrueOperatorWeights` is + // a VIEW that computes projected cursors without writing them; cursors are + // committed only on a successful bill or period skip, so a failed try-bill + // does not advance state and cannot be used to consume periods for free. + BillWeights memory w = _accrueOperatorWeights(serviceId, operators, periodEnd); + + PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; + + // Baseline must have been pinned at activation; a zero here means a service was + // activated via a non-canonical path that skipped baseline seeding. Reverting + // loudly prevents a stake-ramp attacker from front-running baseline at first bill. + if (escrow.subscriptionBaselineStake == 0) { + revert Errors.SubscriptionBaselineNotInitialized(serviceId); + } + uint256 amount = PaymentLib.twapBillAmount( + nominalRate, w.cumDeltaPeriod, escrow.subscriptionBaselineStake, uint256(interval) + ); + + // Bound the bill at the nominal rate: operators ramping stake cannot inflate + // the customer's bill, but ramping-down still reduces it. Per-op weights stay + // uncapped so ramping operators earn a larger slice of the same (capped) pool. + // Cap also keeps `terminateServiceForNonPayment`'s `balance < rate` eligibility + // consistent with the bill's `balance >= amount` requirement. + if (amount > nominalRate) amount = nominalRate; + + // Manager QoS hook can discount (never inflate) the bill. Hook failures / + // out-of-range returns fall back to the cap-resolved amount. + uint16 qosBps = _resolveBillAdjustmentBps(svc.blueprintId, serviceId, periodStart, periodEnd); + if (qosBps < BPS_DENOMINATOR) { + uint256 adjusted = PaymentLib.applyQosAdjustment(amount, qosBps); + emit SubscriptionBillAdjustedByManager(serviceId, amount, adjusted, qosBps); + amount = adjusted; + } + + // Insufficient escrow: do NOT commit cursors and do NOT advance `lastPaymentAt`. + // The period stays due so `terminateServiceForNonPayment` remains the canonical + // recovery path and a future top-up + retry processes the same window. + if (amount > 0 && escrow.balance < amount) { + if (revertOnFail) revert Errors.InsufficientEscrowBalance(amount, escrow.balance); + return (false, 0); + } + + // From here every exit path commits the cursors and advances `lastPaymentAt`. + // Cursor SSTOREs land BEFORE any external transfer in `_distributeBill` so a + // reverting transfer never leaves cursors stale. + _commitOperatorCursors(serviceId, operators, w.projectedByOpAsset); + svc.lastPaymentAt = periodEnd; + + // Skip-on-dust: a bill that rounds to less than 1 wei per recipient is treated + // as a zero-cost processed period rather than reverting in `_distributeBill`. + if (amount > 0 && amount < PaymentLib.minBillAmount(_paymentSplit, operators.length)) { + emit SubscriptionBilled(serviceId, 0, interval); + return (true, 0); + } + + if (amount == 0) { + emit SubscriptionBilled(serviceId, 0, interval); + return (true, 0); + } + + address token = PaymentLib.releaseFromEscrow(escrow, amount); + ITanglePaymentsInternal(address(this)).distributeBillWithKeeper( + ITanglePaymentsInternal.BillDistribution({ + serviceId: serviceId, + blueprintId: svc.blueprintId, + token: token, + amount: amount, + operators: operators, + weights: w.weights, + totalWeight: w.totalWeight, + hasSecurityCommitments: w.hasSecurityCommitments, + keeper: keeper + }) + ); + + emit SubscriptionBilled(serviceId, amount, interval); + return (true, amount); + } + + /// @notice Persist the projected TWAP cursors after a bill has passed all guard checks. + /// @dev Split out from `_accrueOperatorWeights` so a failed try-bill (insufficient escrow) + /// cannot advance cursors, which would let a service owner consume the period for + /// free on a subsequent retry by zeroing out cumDelta. + function _commitOperatorCursors( + uint64 serviceId, + address[] memory operators, + uint256[][] memory projectedByOpAsset + ) + internal + { + Types.Asset memory bondAsset = _bondAssetForBilling(); + for (uint256 i = 0; i < operators.length;) { + address op = operators[i]; + Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; + uint256[] memory projected = projectedByOpAsset[i]; + uint256 m = commitments.length; + if (m == 0) { + // Fallback path: single bond-asset cursor. `projected` was sized to 1 by + // `_accrueOperatorWeights` to mirror this shape. + bytes32 assetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); + _twapCursorByOpAsset[serviceId][op][assetHash] = projected[0]; + } else { + for (uint256 j = 0; j < m;) { + bytes32 assetHash = keccak256(abi.encode(commitments[j].asset.kind, commitments[j].asset.token)); + _twapCursorByOpAsset[serviceId][op][assetHash] = projected[j]; + unchecked { + ++j; + } + } + } + unchecked { + ++i; + } + } + } + + /// @notice Per-bill state computed from per-(operator, asset) stake-seconds. + /// @dev `projectedByOpAsset[i]` is a jagged inner array indexed by the operator's + /// `AssetSecurityCommitment[j]`. Cursors are committed only after the bill passes + /// the escrow-balance check so a failed try-bill cannot advance state. + struct BillWeights { + uint256 cumDeltaPeriod; + uint256[] weights; // per-operator (exposure-weighted across assets) + uint256[][] projectedByOpAsset; // per-(operator, asset_in_commitments) + uint256 totalWeight; + bool hasSecurityCommitments; + } + + function _accrueOperatorWeights( + uint64 serviceId, + address[] memory operators, + uint64 periodEnd + ) + internal + view + returns (BillWeights memory result) + { + IStaking staking = _staking; + address oracleAddr = _priceOracle; + bool useOracle = oracleAddr != address(0); + IPriceOracle oracle = IPriceOracle(oracleAddr); + Types.Asset memory bondAsset = _bondAssetForBilling(); + uint256 tailSeconds = block.timestamp > periodEnd ? block.timestamp - uint256(periodEnd) : 0; + + uint256 n = operators.length; + result.weights = new uint256[](n); + result.projectedByOpAsset = new uint256[][](n); + + for (uint256 i = 0; i < n;) { + address op = operators[i]; + Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; + uint256 m = commitments.length; + uint256 opWeight; + uint256[] memory projected; + if (m == 0) { + // Fallback: no per-asset commitments → treat as a single implicit + // commitment to the bond asset at the operator's overall + // `ServiceOperator.exposureBps`. Mirrors `_initSubscriptionBaseline`. + projected = new uint256[](1); + bytes32 assetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); + (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, bondAsset); + uint256 projectedCum = _projectToPeriodEnd(cumOp, stakeOp, tailSeconds); + uint256 cursor = _twapCursorByOpAsset[serviceId][op][assetHash]; + uint256 opDeltaRaw; + if (cursor != 0 && projectedCum > cursor) { + opDeltaRaw = projectedCum - cursor; + } + projected[0] = projectedCum; + + uint16 fallbackBps = _serviceOperators[serviceId][op].exposureBps; + if (fallbackBps == 0) fallbackBps = uint16(BPS_DENOMINATOR); + uint256 contribution = (opDeltaRaw * uint256(fallbackBps)) / BPS_DENOMINATOR; + if (useOracle && contribution > 0) { + address token = bondAsset.kind == Types.AssetKind.Native ? address(0) : bondAsset.token; + contribution = oracle.toUSD(token, contribution); + } + opWeight = contribution; + if (!result.hasSecurityCommitments && stakeOp > 0 && fallbackBps > 0) { + result.hasSecurityCommitments = true; + } + } else { + projected = new uint256[](m); + for (uint256 j = 0; j < m;) { + Types.AssetSecurityCommitment storage c = commitments[j]; + bytes32 assetHash = keccak256(abi.encode(c.asset.kind, c.asset.token)); + (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, c.asset); + uint256 projectedCum = _projectToPeriodEnd(cumOp, stakeOp, tailSeconds); + uint256 cursor = _twapCursorByOpAsset[serviceId][op][assetHash]; + uint256 opDeltaRaw; + if (cursor != 0 && projectedCum > cursor) { + opDeltaRaw = projectedCum - cursor; + } + projected[j] = projectedCum; + + // Exposure-weighted contribution. With oracle: USD-normalized so + // heterogeneous assets aggregate by value. Without oracle: raw + // token-second amounts — comparable only when all committed assets + // share a unit, but proportional within that unit. + uint256 contribution = (opDeltaRaw * uint256(c.exposureBps)) / BPS_DENOMINATOR; + if (useOracle && contribution > 0) { + address token = c.asset.kind == Types.AssetKind.Native ? address(0) : c.asset.token; + contribution = oracle.toUSD(token, contribution); + } + opWeight += contribution; + if (!result.hasSecurityCommitments && stakeOp > 0 && c.exposureBps > 0) { + result.hasSecurityCommitments = true; + } + unchecked { + ++j; + } + } + } + result.projectedByOpAsset[i] = projected; + result.weights[i] = opWeight; + result.totalWeight += opWeight; + result.cumDeltaPeriod += opWeight; + unchecked { + ++i; + } + } + + // Fallback weighting: when cumDelta is zero across the board (e.g. genuine zero-stake + // edge cases) OR every operator has zero exposureBps, distribute the operator pool + // equally across active operators so the bill — if any — still reaches them. + if (result.totalWeight == 0 && operators.length > 0) { + for (uint256 i = 0; i < operators.length;) { + result.weights[i] = 1; + unchecked { + ++i; + } + } + result.totalWeight = operators.length; + } + + // `hasSecurityCommitments` was set during the per-(op, asset) loop above: true iff + // any operator has non-zero current stake committed with non-zero `exposureBps` on + // any asset. Controls whether the staker pool routes to the `ServiceFeeDistributor` + // or folds into the operator pool. + } + + /// @notice Resolve the per-period bill adjustment from the blueprint's manager hook. + /// @dev Best-effort with a hard gas cap (`MANAGER_HOOK_GAS_LIMIT`). Any revert / + /// out-of-range return / zero manager yields a full-bill (10_000 bps) result. + /// Values above 10_000 are clamped — a misbehaving manager cannot inflate a + /// customer's bill, only discount it. The gas cap prevents a malicious + /// manager from looping out the keeper's gas budget to make permissionless + /// bill triggers unprofitable. + function _resolveBillAdjustmentBps( + uint64 blueprintId, + uint64 serviceId, + uint64 periodStart, + uint64 periodEnd + ) + internal + view + returns (uint16) + { + address manager = _blueprints[blueprintId].manager; + if (manager == address(0)) return uint16(BPS_DENOMINATOR); + (bool ok, bytes memory ret) = manager.staticcall{ gas: MANAGER_HOOK_GAS_LIMIT }( + abi.encodeWithSelector(IBlueprintServiceManager.computeBillAdjustmentBps.selector, serviceId, periodStart, periodEnd) + ); + if (!ok || ret.length < 32) return uint16(BPS_DENOMINATOR); + uint256 bps = abi.decode(ret, (uint256)); + if (bps >= BPS_DENOMINATOR) return uint16(BPS_DENOMINATOR); + return uint16(bps); + } + + /// @notice Forward-project an operator's cum stake-seconds to the period boundary. + /// @dev `tailSeconds` is `block.timestamp - periodEnd` when the bill is late, or + /// zero when on-time. Exact when stake has been stable since `periodEnd`; + /// conservatively under-attributes when stake ramped down in the tail. + function _projectToPeriodEnd(uint256 cumNow, uint256 stakeNow, uint256 tailSeconds) + internal + pure + returns (uint256) + { + if (tailSeconds == 0) return cumNow; + uint256 tail = stakeNow * tailSeconds; + return cumNow > tail ? cumNow - tail : 0; + } + +} diff --git a/src/core/PaymentsCore.sol b/src/core/PaymentsCore.sol new file mode 100644 index 00000000..e53ad556 --- /dev/null +++ b/src/core/PaymentsCore.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import { Base } from "./Base.sol"; +import { PaymentLib } from "../libraries/PaymentLib.sol"; + +/// @title PaymentsCore +/// @notice Shared events and lightweight helpers for the payment subsystem. +/// @dev Holds the events emitted by both billing and distribution paths, the active-operator +/// enumeration helper used by billing, and the escrow deposit helper used by event-driven +/// flows. Distribution machinery (`_distributeBill` and downstream) lives in +/// `PaymentsDistribution` and is reached from billing via a diamond self-call so the +/// billing facet does not inline its bytecode. +abstract contract PaymentsCore is Base { + using EnumerableSet for EnumerableSet.AddressSet; + + // ═══════════════════════════════════════════════════════════════════════════ + // EVENTS + // ═══════════════════════════════════════════════════════════════════════════ + + event EscrowFunded(uint64 indexed serviceId, address indexed token, uint256 amount); + event EscrowRefunded(uint64 indexed serviceId, address indexed owner, address indexed token, uint256 amount); + event SubscriptionBilled(uint64 indexed serviceId, uint256 amount, uint64 period); + /// @notice Emitted when a subscription's bill window elapses but no active operators + /// exist to bill against. The `lastPaymentAt` cursor advances by `period` to + /// keep the schedule on rails; the escrow is not touched. + event SubscriptionBillSkippedNoOperators(uint64 indexed serviceId, uint64 period); + /// @notice Emitted when the manager hook reduced the bill via `computeBillAdjustmentBps`. + /// @dev `preAdjustmentAmount` is the TWAP-and-cap-resolved amount (NOT the blueprint's + /// nominal rate). `adjustedAmount` is what the protocol ultimately drew from escrow. + event SubscriptionBillAdjustedByManager( + uint64 indexed serviceId, uint256 preAdjustmentAmount, uint256 adjustedAmount, uint16 adjustmentBps + ); + /// @notice Emitted when a Subscription-pricing service has its per-operator TWAP cursors + /// and `subscriptionBaselineStake` seeded at activation. Indexers / off-chain + /// observers can subscribe here to track when the bill contract is locked in. + event SubscriptionBaselineInitialized(uint64 indexed serviceId, uint256 baselineStake, uint256 operatorCount); + + /// @dev Returns only operators currently active in the service. Operators that left + /// remain in the EnumerableSet for historical accounting; we must not pay them. + function _activeServiceOperators(uint64 serviceId) internal view returns (address[] memory active) { + address[] memory all = _serviceOperatorSet[serviceId].values(); + uint256 activeCount; + for (uint256 i = 0; i < all.length; ++i) { + if (_serviceOperators[serviceId][all[i]].active) activeCount++; + } + active = new address[](activeCount); + uint256 j; + for (uint256 i = 0; i < all.length; ++i) { + if (_serviceOperators[serviceId][all[i]].active) { + active[j++] = all[i]; + } + } + } + + /// @notice Deposit to escrow. + function _depositToEscrow(uint64 serviceId, address token, uint256 amount) internal { + PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; + escrow.token = token; + escrow.balance += amount; + escrow.totalDeposited += amount; + emit EscrowFunded(serviceId, token, amount); + } +} diff --git a/src/core/PaymentsDistribution.sol b/src/core/PaymentsDistribution.sol new file mode 100644 index 00000000..112d5ffd --- /dev/null +++ b/src/core/PaymentsDistribution.sol @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import { PaymentsCore } from "./PaymentsCore.sol"; +import { PaymentsEffectiveExposure } from "./PaymentsEffectiveExposure.sol"; +import { Types } from "../libraries/Types.sol"; +import { Errors } from "../libraries/Errors.sol"; +import { PaymentLib } from "../libraries/PaymentLib.sol"; +import { IBlueprintServiceManager } from "../interfaces/IBlueprintServiceManager.sol"; +import { IServiceFeeDistributor } from "../interfaces/IServiceFeeDistributor.sol"; +import { IStaking } from "../interfaces/IStaking.sol"; +import { IPriceOracle } from "../oracles/interfaces/IPriceOracle.sol"; +import { ITanglePaymentsInternal } from "../interfaces/ITanglePaymentsInternal.sol"; + +/// @title PaymentsDistribution +/// @notice Sole owner of bill distribution: shared distribute path, exposure-weighted entry, +/// keeper-rebate entry, and the staker-share routing to the fee distributor. +/// @dev Lives on the dedicated distribution facet so the billing facet does not inline this +/// machinery. The subscription billing path reaches it via a self-call through the +/// diamond (`ITanglePaymentsInternal.distributeBillWithKeeper`). +abstract contract PaymentsDistribution is PaymentsCore, PaymentsEffectiveExposure { + using EnumerableSet for EnumerableSet.AddressSet; + + /// @notice Emitted on every bill caller's keeper rebate. + event KeeperRebateAccrued(uint64 indexed serviceId, address indexed keeper, address indexed token, uint256 amount); + /// @notice Emitted when the staker pool's share could not be routed (no distributor configured, + /// or the distributor reverted). The amount is refunded to the service escrow so the + /// customer can recover it, rather than being silently captured by the treasury. + event StakerShareRefundedToEscrow( + uint64 indexed serviceId, address indexed operator, address indexed token, uint256 amount, bytes reason + ); + event PaymentDistributed( + uint64 indexed serviceId, + uint64 indexed blueprintId, + address indexed token, + uint256 grossAmount, + address developerRecipient, + uint256 developerAmount, + uint256 protocolAmount, + uint256 operatorPoolAmount, + uint256 stakerPoolAmount + ); + event OperatorRewardAccrued( + uint64 indexed serviceId, address indexed operator, address indexed token, uint64 blueprintId, uint256 amount + ); + event TntPaymentDiscountApplied( + uint64 indexed serviceId, address indexed recipient, address indexed token, uint256 amount + ); + + /// @notice Backwards-compatible entry point for non-subscription distributions + /// (one-shot, RFQ, per-job). Computes exposure-based weights internally and + /// routes through the shared `_distributeBill` core with no keeper rebate. + function _distributePaymentWithEffectiveExposure( + uint64 serviceId, + uint64 blueprintId, + address token, + uint256 amount, + address[] memory operators, + uint256[] memory effectiveExposures, + uint256 totalEffectiveExposure, + bool hasSecurityCommitments + ) + internal + { + // Fallback weighting: when nobody has effective exposure, distribute the operator + // pool equally. Materializing this as a uniform `weights` array keeps the shared + // core simple at a marginal gas cost. + uint256[] memory weights = effectiveExposures; + uint256 totalWeight = totalEffectiveExposure; + if (totalWeight == 0 && operators.length > 0) { + weights = new uint256[](operators.length); + for (uint256 i = 0; i < operators.length;) { + weights[i] = 1; + unchecked { + ++i; + } + } + totalWeight = operators.length; + } + + _distributeBill( + ITanglePaymentsInternal.BillDistribution({ + serviceId: serviceId, + blueprintId: blueprintId, + token: token, + amount: amount, + operators: operators, + weights: weights, + totalWeight: totalWeight, + hasSecurityCommitments: hasSecurityCommitments, + keeper: address(0) + }) + ); + } + + /// @notice Initialize per-(operator, asset) TWAP cursors and pin the multi-asset baseline. + /// @dev Walks each operator's `AssetSecurityCommitment[]` and seeds cursors for every + /// (op, asset) pair. Baseline is the exposure-weighted aggregate + /// `Σ_op Σ_asset (delegation × commitmentBps)`, USD-normalized when a price oracle + /// is configured. Pinned once at activation; subsequent bills measure against this + /// snapshot so an operator cannot inflate the customer's bill by ramping stake on + /// a single asset post-activation. + function _initSubscriptionBaseline(uint64 serviceId, address[] calldata operators) internal { + IStaking staking = _staking; + address oracleAddr = _priceOracle; + bool useOracle = oracleAddr != address(0); + IPriceOracle oracle = IPriceOracle(oracleAddr); + Types.Asset memory bondAsset = _bondAssetForBilling(); + + uint256 baseline; + uint256 n = operators.length; + for (uint256 i = 0; i < n;) { + address op = operators[i]; + Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; + uint256 m = commitments.length; + if (m == 0) { + bytes32 assetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); + (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, bondAsset); + _twapCursorByOpAsset[serviceId][op][assetHash] = cumOp == 0 ? 1 : cumOp; + uint16 fallbackBps = _serviceOperators[serviceId][op].exposureBps; + if (fallbackBps == 0) fallbackBps = uint16(BPS_DENOMINATOR); + uint256 exposedAmount = (stakeOp * uint256(fallbackBps)) / BPS_DENOMINATOR; + if (useOracle && exposedAmount > 0) { + address token = bondAsset.kind == Types.AssetKind.Native ? address(0) : bondAsset.token; + baseline += oracle.toUSD(token, exposedAmount); + } else { + baseline += exposedAmount; + } + } else { + for (uint256 j = 0; j < m;) { + Types.AssetSecurityCommitment storage c = commitments[j]; + bytes32 assetHash = keccak256(abi.encode(c.asset.kind, c.asset.token)); + (uint256 cumOp,, uint256 stakeOp) = staking.getCumStakeSeconds(op, c.asset); + _twapCursorByOpAsset[serviceId][op][assetHash] = cumOp == 0 ? 1 : cumOp; + + uint256 exposedAmount = (stakeOp * uint256(c.exposureBps)) / BPS_DENOMINATOR; + if (useOracle && exposedAmount > 0) { + address token = c.asset.kind == Types.AssetKind.Native ? address(0) : c.asset.token; + baseline += oracle.toUSD(token, exposedAmount); + } else { + baseline += exposedAmount; + } + unchecked { + ++j; + } + } + } + unchecked { + ++i; + } + } + // Pathological zero-stake activation: defensive minimum of 1 keeps the denominator + // positive. In practice activation requires staked, committed operators. + uint256 pinned = baseline == 0 ? 1 : baseline; + _serviceEscrows[serviceId].subscriptionBaselineStake = pinned; + emit SubscriptionBaselineInitialized(serviceId, pinned, n); + } + + /// @notice Calculate effective exposures with fallback to stored exposureBps + /// @dev When operators have no security commitments, falls back to the exposureBps + /// stored on their ServiceOperator record for proportional distribution. + function _calculateEffectiveExposuresWithFallback( + uint64 serviceId, + address[] memory operators + ) + internal + view + returns (uint256[] memory effectiveExposures, uint256 totalEffectiveExposure, bool hasSecurityCommitments) + { + (effectiveExposures, totalEffectiveExposure) = _calculateEffectiveExposures(serviceId, operators); + + hasSecurityCommitments = totalEffectiveExposure > 0; + + if (totalEffectiveExposure == 0 && operators.length > 0) { + uint16[] memory bps = new uint16[](operators.length); + for (uint256 i = 0; i < operators.length;) { + bps[i] = _serviceOperators[serviceId][operators[i]].exposureBps; + unchecked { + ++i; + } + } + (effectiveExposures, totalEffectiveExposure) = _calculateSimpleExposures(operators, bps); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SHARED DISTRIBUTION CORE + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice Distribute a bill to (developer, protocol, operator pool, staker pool, keeper). + /// @dev Single distribution path shared between subscription bills (keeper present, TWAP + /// weights) and non-subscription payments (no keeper, exposure-based weights). The + /// caller is responsible for computing weights — this function does not assume any + /// particular fairness model for the operator pool. + function _distributeBill(ITanglePaymentsInternal.BillDistribution memory d) internal { + if (d.amount == 0) return; + if (d.operators.length == 0) revert Errors.NoOperators(); + + bool includeKeeper = d.keeper != address(0); + if (d.totalWeight == 0) revert Errors.InvalidState(); + PaymentLib.PaymentAmounts memory amounts = PaymentLib.calculateSplit(d.amount, _paymentSplit, includeKeeper); + + // Developer payment (manager can override the destination). + Types.Blueprint storage bp = _blueprints[d.blueprintId]; + Types.Service storage svc = _services[d.serviceId]; + address developerAddr = _resolveDeveloperPaymentAddress(bp.manager, bp.owner, d.serviceId); + PaymentLib.transferPayment(developerAddr, d.token, amounts.developerAmount); + + // TNT payment discount: funded from the protocol share, paid to the service owner. + if ( + d.token != address(0) && d.token == _tntToken && _tntPaymentDiscountBps > 0 && amounts.protocolAmount > 0 + && svc.owner != address(0) + ) { + uint256 desired = (d.amount * _tntPaymentDiscountBps) / BPS_DENOMINATOR; + uint256 discount = desired > amounts.protocolAmount ? amounts.protocolAmount : desired; + if (discount > 0) { + amounts.protocolAmount -= discount; + PaymentLib.transferPayment(svc.owner, d.token, discount); + emit TntPaymentDiscountApplied(d.serviceId, svc.owner, d.token, discount); + } + } + + PaymentLib.transferPayment(_treasury, d.token, amounts.protocolAmount); + + // Keeper rebate: pull-pattern via _pendingRewards so the keeper's gas budget for + // this transaction stays predictable and contract-keepers don't get force-fed ETH. + if (includeKeeper && amounts.keeperAmount > 0) { + PaymentLib.addPendingReward(_pendingRewards, d.keeper, d.token, amounts.keeperAmount); + _pendingRewardTokens[d.keeper].add(d.token); + emit KeeperRebateAccrued(d.serviceId, d.keeper, d.token, amounts.keeperAmount); + } + + // When no real delegated stake backs operators, fold the staker share into the + // operator pool so the customer still funds compute providers in full. + uint256 operatorPool = + d.hasSecurityCommitments ? amounts.operatorAmount : amounts.operatorAmount + amounts.stakerAmount; + uint256 stakerPool = d.hasSecurityCommitments ? amounts.stakerAmount : 0; + + emit PaymentDistributed( + d.serviceId, + d.blueprintId, + d.token, + d.amount, + developerAddr, + amounts.developerAmount, + amounts.protocolAmount, + operatorPool, + stakerPool + ); + + _payOperatorPoolByWeight(d, operatorPool, stakerPool); + } + + /// @notice Distribute the operator + staker pools across active operators by `weights`. + /// @dev `_distributeBill` ensures `weights.length == operators.length`, `totalWeight > 0`, + /// and any rounding dust accumulates on the LAST operator so Σshares == pool exactly. + function _payOperatorPoolByWeight( + ITanglePaymentsInternal.BillDistribution memory d, + uint256 operatorPool, + uint256 stakerPool + ) + internal + { + uint256 n = d.operators.length; + uint256 operatorDistributed; + uint256 stakerDistributed; + + for (uint256 i = 0; i < n;) { + uint256 opShare; + uint256 stakerShare; + if (i == n - 1) { + opShare = operatorPool - operatorDistributed; + stakerShare = stakerPool - stakerDistributed; + } else { + opShare = (operatorPool * d.weights[i]) / d.totalWeight; + stakerShare = (stakerPool * d.weights[i]) / d.totalWeight; + operatorDistributed += opShare; + stakerDistributed += stakerShare; + } + + if (opShare > 0) { + PaymentLib.addPendingReward(_pendingRewards, d.operators[i], d.token, opShare); + _pendingRewardTokens[d.operators[i]].add(d.token); + emit OperatorRewardAccrued(d.serviceId, d.operators[i], d.token, d.blueprintId, opShare); + } + if (stakerShare > 0) { + _forwardStakerShare(d.serviceId, d.blueprintId, d.operators[i], d.token, stakerShare); + } + unchecked { + ++i; + } + } + } + + /// @notice Route the staker pool's per-operator share through the fee distributor. + /// @dev When the distributor is unset OR reverts, the share is refunded to the service + /// escrow rather than silently captured by the treasury. + function _forwardStakerShare( + uint64 serviceId, + uint64 blueprintId, + address operator, + address token, + uint256 amount + ) + private + { + if (amount == 0) return; + address distributor = _serviceFeeDistributor; + + if (distributor == address(0)) { + _refundStakerShareToEscrow(serviceId, operator, token, amount, bytes("no-distributor")); + return; + } + + if (token == address(0)) { + try IServiceFeeDistributor(distributor).distributeServiceFee{ value: amount }( + serviceId, blueprintId, operator, token, amount + ) { + return; + } catch (bytes memory reason) { + _refundStakerShareToEscrow(serviceId, operator, token, amount, reason); + return; + } + } + + PaymentLib.transferPayment(distributor, token, amount); + try IServiceFeeDistributor(distributor).distributeServiceFee(serviceId, blueprintId, operator, token, amount) { + return; + } catch (bytes memory reason) { + // The ERC20 has already left this contract — fee distributor holds it. We cannot + // unilaterally claw the tokens back, so we emit a clear marker for the customer + // and protocol to handle off-chain. + emit StakerShareRefundedToEscrow(serviceId, operator, token, amount, reason); + } + } + + function _refundStakerShareToEscrow( + uint64 serviceId, + address operator, + address token, + uint256 amount, + bytes memory reason + ) + private + { + PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; + if (escrow.token != token) { + PaymentLib.transferPayment(_treasury, token, amount); + return; + } + escrow.balance += amount; + if (escrow.totalReleased >= amount) { + escrow.totalReleased -= amount; + } else { + escrow.totalReleased = 0; + } + emit StakerShareRefundedToEscrow(serviceId, operator, token, amount, reason); + } + + /// @notice Resolve the developer payment recipient via a gas-capped manager hook. + /// @dev Bounded gas, raw staticcall, fall back to `blueprintOwner` on revert / empty / zero. + function _resolveDeveloperPaymentAddress( + address manager, + address blueprintOwner, + uint64 serviceId + ) + internal + view + returns (address) + { + if (manager == address(0)) return blueprintOwner; + (bool ok, bytes memory ret) = manager.staticcall{ gas: MANAGER_HOOK_GAS_LIMIT }( + abi.encodeWithSelector(IBlueprintServiceManager.queryDeveloperPaymentAddress.selector, serviceId) + ); + if (!ok || ret.length < 32) return blueprintOwner; + address dev = abi.decode(ret, (address)); + return dev == address(0) ? blueprintOwner : dev; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // EFFECTIVE EXPOSURE INTERFACE IMPLEMENTATIONS + // ═══════════════════════════════════════════════════════════════════════════ + + /// @inheritdoc PaymentsEffectiveExposure + function _getStaking() internal view override returns (IStaking) { + return _staking; + } + + /// @inheritdoc PaymentsEffectiveExposure + function _getPriceOracle() internal view override returns (address) { + return _priceOracle; + } + + /// @inheritdoc PaymentsEffectiveExposure + function _getServiceSecurityCommitments( + uint64 serviceId, + address operator + ) + internal + view + override + returns (Types.AssetSecurityCommitment[] storage) + { + return _serviceSecurityCommitments[serviceId][operator]; + } +} diff --git a/src/core/PaymentsEscrow.sol b/src/core/PaymentsEscrow.sol new file mode 100644 index 00000000..35170aa1 --- /dev/null +++ b/src/core/PaymentsEscrow.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { PaymentsCore } from "./PaymentsCore.sol"; +import { Types } from "../libraries/Types.sol"; +import { Errors } from "../libraries/Errors.sol"; +import { PaymentLib } from "../libraries/PaymentLib.sol"; + +/// @title PaymentsEscrow +/// @notice Customer-facing escrow funding and post-termination withdrawal. +abstract contract PaymentsEscrow is PaymentsCore { + using PaymentLib for PaymentLib.ServiceEscrow; + + /// @notice Fund a service's escrow. + /// @dev Re-checks (a) the service hasn't expired and (b) the blueprint manager still + /// whitelists the escrow's payment token. Without these checks a service could + /// be funded after expiry (escrow stuck) or after a manager policy revoke + /// (ongoing top-ups for a token the protocol now disallows). + function fundService(uint64 serviceId, uint256 amount) external payable whenNotPaused nonReentrant { + Types.Service storage svc = _getService(serviceId); + if (svc.status != Types.ServiceStatus.Active) { + revert Errors.ServiceNotActive(serviceId); + } + if (svc.pricing != Types.PricingModel.Subscription) { + revert Errors.InvalidState(); + } + if (svc.ttl > 0 && block.timestamp > svc.createdAt + svc.ttl) { + revert Errors.ServiceExpired(serviceId); + } + + PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; + address token = escrow.token; + + Types.Blueprint storage bp = _blueprints[svc.blueprintId]; + if (bp.manager != address(0) && !_isPaymentAssetAllowedByManager(bp.manager, serviceId, token)) { + revert Errors.TokenNotAllowed(token); + } + + PaymentLib.depositToEscrow(escrow, token, amount, msg.value); + + emit EscrowFunded(serviceId, token, amount); + _recordPayment(msg.sender, serviceId, token, amount); + } + + /// @notice Withdraw remaining escrow balance after service termination + function withdrawRemainingEscrow(uint64 serviceId) external nonReentrant { + Types.Service storage svc = _getService(serviceId); + if (svc.owner != msg.sender) { + revert Errors.NotServiceOwner(serviceId, msg.sender); + } + if (svc.status != Types.ServiceStatus.Terminated) { + revert Errors.ServiceNotTerminated(serviceId); + } + + PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; + uint256 remaining = escrow.balance; + if (remaining == 0) revert Errors.ZeroAmount(); + + address token = escrow.token; + escrow.balance = 0; + escrow.totalReleased += remaining; + + PaymentLib.transferPayment(svc.owner, token, remaining); + emit EscrowRefunded(serviceId, svc.owner, token, remaining); + } +} diff --git a/src/core/PaymentsRewards.sol b/src/core/PaymentsRewards.sol new file mode 100644 index 00000000..96fe845e --- /dev/null +++ b/src/core/PaymentsRewards.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import { Base } from "./Base.sol"; +import { Types } from "../libraries/Types.sol"; +import { Errors } from "../libraries/Errors.sol"; +import { PaymentLib } from "../libraries/PaymentLib.sol"; + +/// @title PaymentsRewards +/// @notice Rewards claim + payment-split / treasury admin + escrow views. +/// @dev Split out from `Payments` so a dedicated facet can host these selectors +/// without dragging in the subscription-billing and distribution machinery. +/// All storage is shared with `Payments` via `Base`/`TangleStorage` slots. +abstract contract PaymentsRewards is Base { + using EnumerableSet for EnumerableSet.AddressSet; + + event RewardsClaimed(address indexed account, address indexed token, uint256 amount); + + // ═══════════════════════════════════════════════════════════════════════════ + // REWARDS + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice Claim pending rewards (native token) + function claimRewards() external nonReentrant { + _claimRewardsToken(msg.sender, address(0), false); + } + + /// @notice Claim pending rewards for specific token + function claimRewards(address token) external nonReentrant { + _claimRewardsToken(msg.sender, token, false); + } + + /// @notice Claim pending rewards for multiple tokens + function claimRewardsBatch(address[] calldata tokens) external nonReentrant { + uint256 tokensLength = tokens.length; + for (uint256 i = 0; i < tokensLength;) { + _claimRewardsToken(msg.sender, tokens[i], false); + unchecked { + ++i; + } + } + } + + /// @notice Claim pending rewards for all tokens tracked for the caller + function claimRewardsAll() external nonReentrant { + EnumerableSet.AddressSet storage set = _pendingRewardTokens[msg.sender]; + while (set.length() > 0) { + address token = set.at(set.length() - 1); + _claimRewardsToken(msg.sender, token, true); + } + } + + /// @notice Get pending rewards + function pendingRewards(address account) external view returns (uint256) { + return _pendingRewards[account][address(0)]; + } + + /// @notice Get pending rewards for token + function pendingRewards(address account, address token) external view returns (uint256) { + return _pendingRewards[account][token]; + } + + /// @notice Return the set of tokens with non-zero pending operator rewards for an account + function rewardTokens(address account) external view returns (address[] memory tokens) { + EnumerableSet.AddressSet storage set = _pendingRewardTokens[account]; + uint256 setLength = set.length(); + tokens = new address[](setLength); + for (uint256 i = 0; i < setLength;) { + tokens[i] = set.at(i); + unchecked { + ++i; + } + } + } + + function _claimRewardsToken(address account, address token, bool forceRemove) private { + uint256 claimed = PaymentLib.claimPendingReward(_pendingRewards, account, token); + if (claimed > 0) { + _pendingRewardTokens[account].remove(token); + emit RewardsClaimed(account, token, claimed); + } else if (forceRemove) { + _pendingRewardTokens[account].remove(token); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ADMIN + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice Set payment split + /// @param split The new payment split configuration + function setPaymentSplit(Types.PaymentSplit calldata split) external onlyRole(ADMIN_ROLE) { + PaymentLib.validateSplit(split); + _paymentSplit = split; + emit PaymentSplitUpdated( + split.developerBps, split.protocolBps, split.operatorBps, split.stakerBps, split.keeperBps + ); + } + + /// @notice Set treasury + /// @param treasury_ The new treasury address + function setTreasury(address payable treasury_) external onlyRole(ADMIN_ROLE) { + if (treasury_ == address(0)) revert Errors.ZeroAddress(); + _treasury = treasury_; + emit TreasuryUpdated(treasury_); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // VIEW + // ═══════════════════════════════════════════════════════════════════════════ + + function paymentSplit() external view returns (uint16, uint16, uint16, uint16, uint16) { + return ( + _paymentSplit.developerBps, + _paymentSplit.protocolBps, + _paymentSplit.operatorBps, + _paymentSplit.stakerBps, + _paymentSplit.keeperBps + ); + } + + function treasury() external view returns (address payable) { + return _treasury; + } + + function getServiceEscrow(uint64 serviceId) external view returns (PaymentLib.ServiceEscrow memory) { + return _serviceEscrows[serviceId]; + } + + /// @notice Filter `serviceIds` down to those eligible for a subscription bill right now. + /// @dev Mirrors `_billSubscriptionImpl`'s pre-conditions: active + subscription-priced + + /// baseline-seeded, past TTL guard, past billing interval, AND escrow can cover the + /// nominal rate (cap-at-nominal means a real bill never exceeds `subscriptionRate`). + /// Off-chain keepers use this to avoid burning gas on bills that will not draw. + function getBillableServices(uint64[] calldata serviceIds) external view returns (uint64[] memory billable) { + uint256 serviceIdsLength = serviceIds.length; + uint64[] memory temp = new uint64[](serviceIdsLength); + uint256 count = 0; + + for (uint256 i = 0; i < serviceIdsLength;) { + if (_isBillable(serviceIds[i])) { + temp[count++] = serviceIds[i]; + } + unchecked { + ++i; + } + } + + billable = new uint64[](count); + for (uint256 i = 0; i < count;) { + billable[i] = temp[i]; + unchecked { + ++i; + } + } + } + + function _isBillable(uint64 serviceId) internal view returns (bool) { + Types.Service storage svc = _services[serviceId]; + if (svc.status != Types.ServiceStatus.Active) return false; + if (svc.pricing != Types.PricingModel.Subscription) return false; + if (svc.ttl > 0 && block.timestamp > svc.createdAt + svc.ttl) return false; + + Types.BlueprintConfig storage bpConfig = _blueprintConfigs[svc.blueprintId]; + if (block.timestamp < svc.lastPaymentAt + bpConfig.subscriptionInterval) return false; + + PaymentLib.ServiceEscrow storage escrow = _serviceEscrows[serviceId]; + if (escrow.subscriptionBaselineStake == 0) return false; + if (escrow.balance < bpConfig.subscriptionRate) return false; + + return true; + } +} diff --git a/src/core/ServicesApprovals.sol b/src/core/ServicesApprovals.sol index 1839b689..473b71f4 100644 --- a/src/core/ServicesApprovals.sol +++ b/src/core/ServicesApprovals.sol @@ -4,11 +4,12 @@ pragma solidity ^0.8.26; import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import { Base } from "./Base.sol"; +import { AttestationLib } from "../libraries/AttestationLib.sol"; import { Types } from "../libraries/Types.sol"; import { Errors } from "../libraries/Errors.sol"; import { PaymentLib } from "../libraries/PaymentLib.sol"; import { IBlueprintServiceManager } from "../interfaces/IBlueprintServiceManager.sol"; -import { BN254 } from "../libraries/BN254.sol"; +import { ServiceValidationLib } from "../libraries/ServiceValidationLib.sol"; import { ProtocolConfig } from "../config/ProtocolConfig.sol"; /// @title ServicesApprovals @@ -37,7 +38,6 @@ abstract contract ServicesApprovals is Base { event ServiceApproved(uint64 indexed requestId, address indexed operator); event ServiceRejected(uint64 indexed requestId, address indexed operator); - event ServiceRequestExpired(uint64 indexed requestId, address indexed expiredBy); /// @notice Emitted on every approval that carries TEE commitments. The /// `commitments` payload is the full array exactly as supplied — @@ -54,22 +54,6 @@ abstract contract ServicesApprovals is Base { Types.TeeAttestationCommitment[] commitments ); - // ═══════════════════════════════════════════════════════════════════════════ - // CONSTANTS - // ═══════════════════════════════════════════════════════════════════════════ - - /// @notice Per-operator cap on TEE attestation commitments at approval. - /// @dev Bounds calldata + validation cost. With root storage, activation - /// gas is operator-linear regardless of this cap; the cap exists to - /// keep the validation loop cheap and the witness array small. - uint256 internal constant MAX_TEE_COMMITMENTS_PER_OPERATOR = 8; - - /// @notice Maximum TTL on an operator's TEE attestation commitment. - /// @dev `expiresAt = type(uint64).max` would otherwise be effectively - /// never-expiring. 90 days is enough headroom for any realistic - /// service lifetime; longer-lived services should re-commit. - uint64 internal constant MAX_TEE_COMMITMENT_TTL = 90 days; - // ═══════════════════════════════════════════════════════════════════════════ // SERVICE APPROVAL — single entrypoint // ═══════════════════════════════════════════════════════════════════════════ @@ -101,7 +85,7 @@ abstract contract ServicesApprovals is Base { bool registeringBls = _isNonZeroBlsPubkey(p.blsPubkey); if (hasRequirements && hasSuppliedCommitments) { - _validateSecurityCommitments(requirements, p.securityCommitments); + ServiceValidationLib.validateSecurityCommitments(requirements, p.securityCommitments); } else if (hasRequirements && !hasSuppliedCommitments) { // Operator omitted commitments — this is only acceptable if the // request's only security requirement is the protocol-default TNT @@ -113,12 +97,18 @@ abstract contract ServicesApprovals is Base { bytes32 teeRoot; if (hasTeeCommitments) { - _validateTeeCommitments(p.requestId, p.teeCommitments); + ServiceValidationLib.validateTeeCommitments( + p.requestId, + p.teeCommitments, + AttestationLib.teeNonce(p.requestId, address(this), block.chainid) + ); teeRoot = keccak256(abi.encode(p.teeCommitments)); } if (registeringBls) { - _requireBlsProofOfPossession(msg.sender, p.blsPubkey, p.blsPopSignature); + ServiceValidationLib.requireBlsProofOfPossession( + msg.sender, p.blsPubkey, p.blsPopSignature, address(this), block.chainid + ); } // Storage writes — every gate above passed. The effective per-operator @@ -186,25 +176,6 @@ abstract contract ServicesApprovals is Base { } } - // ═══════════════════════════════════════════════════════════════════════════ - // PUBLIC VIEWS - // ═══════════════════════════════════════════════════════════════════════════ - - /// @notice Canonical TEE attestation nonce for `requestId` on this contract on this chain. - /// @dev Operators MUST set `TeeAttestationCommitment.nonceBinding` to this exact value. - /// Cross-request attestation replay is structurally impossible: an attestation - /// document binding to nonce N_A cannot satisfy a commitment requiring nonce N_B. - function teeNonceFor(uint64 requestId) public view returns (bytes32) { - return keccak256(abi.encode("tangle.tee.nonce", requestId, address(this), block.chainid)); - } - - /// @notice Domain-separated message every operator must sign with their BLS secret key - /// to register a public key. Bound to chainId + verifying contract + operator - /// address so a PoP from one chain or operator cannot be replayed. - function blsPopMessage(address operator, uint256[4] memory blsPubkey) public view returns (bytes memory) { - return abi.encode("TANGLE_BLS_POP_v1", block.chainid, address(this), operator, blsPubkey); - } - // ═══════════════════════════════════════════════════════════════════════════ // INTERNAL — auth, validation, helpers // ═══════════════════════════════════════════════════════════════════════════ @@ -228,130 +199,11 @@ abstract contract ServicesApprovals is Base { return false; } - /// @notice Validate operator-supplied TEE attestation commitments. - /// @dev Reverts on: list too long; `Unset` or `DirectTdx` backend; nonce binding - /// that isn't the request-derived value; zero expected-measurement; expiry - /// in the past or further out than `MAX_TEE_COMMITMENT_TTL`. - function _validateTeeCommitments( - uint64 requestId, - Types.TeeAttestationCommitment[] calldata teeCommitments - ) - internal - view - { - if (teeCommitments.length > MAX_TEE_COMMITMENTS_PER_OPERATOR) { - revert Errors.TooManyTeeCommitments(teeCommitments.length, MAX_TEE_COMMITMENTS_PER_OPERATOR); - } - bytes32 expectedNonce = teeNonceFor(requestId); - uint64 nowTs = uint64(block.timestamp); - uint64 maxExpiresAt = nowTs + MAX_TEE_COMMITMENT_TTL; - for (uint256 i = 0; i < teeCommitments.length; i++) { - Types.TeeBackend backend = teeCommitments[i].backend; - if (backend == Types.TeeBackend.Unset) revert Errors.UnsetTeeBackend(); - if (backend == Types.TeeBackend.DirectTdx) revert Errors.DirectTdxNotPermitted(); - if (teeCommitments[i].nonceBinding != expectedNonce) revert Errors.InvalidNonceBinding(); - if (teeCommitments[i].expectedMeasurement == bytes32(0)) revert Errors.InvalidExpectedMeasurement(); - uint64 expiresAt = teeCommitments[i].expiresAt; - if (expiresAt != 0) { - if (expiresAt <= nowTs) revert Errors.TeeCommitmentExpired(expiresAt, nowTs); - if (expiresAt > maxExpiresAt) revert Errors.TeeCommitmentExpiryTooFar(expiresAt, maxExpiresAt); - } - } - } - - /// @notice Validate operator security commitments against on-chain requirements. - function _validateSecurityCommitments( - Types.AssetSecurityRequirement[] storage requirements, - Types.AssetSecurityCommitment[] calldata commitments - ) - internal - view - { - for (uint256 i = 0; i < commitments.length; i++) { - for (uint256 j = i + 1; j < commitments.length; j++) { - if ( - commitments[i].asset.token == commitments[j].asset.token - && commitments[i].asset.kind == commitments[j].asset.kind - ) { - revert Errors.DuplicateAssetCommitment(uint8(commitments[i].asset.kind), commitments[i].asset.token); - } - } - } - - for (uint256 i = 0; i < requirements.length; i++) { - Types.AssetSecurityRequirement storage req = requirements[i]; - bool found = false; - - for (uint256 j = 0; j < commitments.length; j++) { - if (commitments[j].asset.token == req.asset.token && commitments[j].asset.kind == req.asset.kind) { - if (commitments[j].exposureBps < req.minExposureBps) { - revert Errors.CommitmentBelowMinimum( - req.asset.token, commitments[j].exposureBps, req.minExposureBps - ); - } - if (commitments[j].exposureBps > req.maxExposureBps) { - revert Errors.CommitmentAboveMaximum( - req.asset.token, commitments[j].exposureBps, req.maxExposureBps - ); - } - found = true; - break; - } - } - - if (!found) revert Errors.MissingAssetCommitment(req.asset.token); - } - } - /// @notice Returns true unless every component of `key` is zero. function _isNonZeroBlsPubkey(uint256[4] memory key) private pure returns (bool) { return key[0] != 0 || key[1] != 0 || key[2] != 0 || key[3] != 0; } - /// @dev Reverts unless `popSignature` is a valid BLS G1 signature over `blsPopMessage` - /// under `blsPubkey`. A successful PoP also implies subgroup membership of the G2 - /// pubkey since `pk = sk * G2_generator` for any honest signer. - function _requireBlsProofOfPossession( - address operator, - uint256[4] memory blsPubkey, - uint256[2] memory popSignature - ) - internal - view - { - bool ok = BN254.verifyBls( - blsPopMessage(operator, blsPubkey), - Types.BN254G1Point({ x: popSignature[0], y: popSignature[1] }), - Types.BN254G2Point({ x: [blsPubkey[0], blsPubkey[1]], y: [blsPubkey[2], blsPubkey[3]] }) - ); - if (!ok) revert Errors.InvalidBLSSignature(); - } - - /// @notice Permissionlessly expire a stale service request and refund the requester. - /// @dev Anyone can call this once `block.timestamp > req.createdAt + grace`. The grace - /// period is `_requestExpiryGracePeriod` (or `ProtocolConfig.REQUEST_EXPIRY_GRACE_PERIOD` - /// when unset). Without this path stale unapproved requests would linger indefinitely - /// with their payment locked; cleanup is now permissionless and incentive-aligned - /// (the requester gets refunded, the caller pays the gas). - /// Refund is bounded to requests that were never activated AND never rejected. - /// `req.activated` is set inside `_activateService` so an activated request — whose - /// `paymentAmount` has already been routed to operators — cannot be drained again. - function expireServiceRequest(uint64 requestId) external nonReentrant { - Types.ServiceRequest storage req = _getServiceRequest(requestId); - if (req.rejected || req.activated) revert Errors.ServiceRequestAlreadyProcessed(requestId); - - uint64 grace = _requestExpiryGracePeriod; - if (grace == 0) grace = ProtocolConfig.REQUEST_EXPIRY_GRACE_PERIOD; - if (block.timestamp <= uint256(req.createdAt) + grace) { - revert Errors.ServiceRequestNotExpired(requestId); - } - - req.rejected = true; - PaymentLib.refundPayment(req.requester, req.paymentToken, req.paymentAmount); - - emit ServiceRequestExpired(requestId, msg.sender); - } - /// @dev Reverts if the request has lingered past its grace window or has already /// been activated. Activated requests are functionally closed: their escrow /// has been routed to the service record and any further mutation here would diff --git a/src/core/ServicesApprovalsViews.sol b/src/core/ServicesApprovalsViews.sol new file mode 100644 index 00000000..89af7d71 --- /dev/null +++ b/src/core/ServicesApprovalsViews.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { Base } from "./Base.sol"; +import { AttestationLib } from "../libraries/AttestationLib.sol"; +import { Types } from "../libraries/Types.sol"; +import { Errors } from "../libraries/Errors.sol"; +import { PaymentLib } from "../libraries/PaymentLib.sol"; +import { ProtocolConfig } from "../config/ProtocolConfig.sol"; + +/// @title ServicesApprovalsViews +/// @notice Read-only helpers + permissionless request-expiry cleanup. +/// @dev Hosts the view selectors on a dedicated facet so the approvals contract does not +/// drag this bytecode into the activation facet. Shared pure compute lives in +/// `AttestationLib` so the approvals flow and the views facet stay byte-identical. +abstract contract ServicesApprovalsViews is Base { + event ServiceRequestExpired(uint64 indexed requestId, address indexed expiredBy); + + /// @notice Canonical TEE attestation nonce for `requestId` on this contract on this chain. + function teeNonceFor(uint64 requestId) external view returns (bytes32) { + return AttestationLib.teeNonce(requestId, address(this), block.chainid); + } + + /// @notice Domain-separated message every operator must sign with their BLS secret key + /// to register a public key. Bound to chainId + verifying contract + operator + /// address so a PoP from one chain or operator cannot be replayed. + function blsPopMessage(address operator, uint256[4] memory blsPubkey) external view returns (bytes memory) { + return AttestationLib.blsPopMessage(operator, blsPubkey, address(this), block.chainid); + } + + /// @notice keccak256 root over an operator's `TeeAttestationCommitment[]` for a service. + /// @dev Slashing / provisioning oracles supply the original array as a witness and verify + /// `keccak256(abi.encode(witness)) == getTeeCommitmentRoot(serviceId, operator)` before + /// treating the witness as authoritative. Returns `bytes32(0)` if the operator + /// approved without TEE commitments. + function getTeeCommitmentRoot(uint64 serviceId, address operator) external view returns (bytes32) { + return _serviceTeeCommitmentRoot[serviceId][operator]; + } + + /// @notice Get operator's BLS public key for a service + /// @param serviceId The service ID + /// @param operator The operator address + /// @return blsPubkey The BLS G2 public key [x0, x1, y0, y1], all zeros if not registered + function getOperatorBlsPubkey( + uint64 serviceId, + address operator + ) + external + view + returns (uint256[4] memory blsPubkey) + { + Types.BLSPubkey storage stored = _serviceOperatorBlsPubkeys[serviceId][operator]; + blsPubkey[0] = stored.key[0]; + blsPubkey[1] = stored.key[1]; + blsPubkey[2] = stored.key[2]; + blsPubkey[3] = stored.key[3]; + } + + /// @notice Permissionlessly expire a stale service request and refund the requester. + /// @dev Anyone can call this once `block.timestamp > req.createdAt + grace`. The grace + /// period is `_requestExpiryGracePeriod` (or `ProtocolConfig.REQUEST_EXPIRY_GRACE_PERIOD` + /// when unset). Without this path stale unapproved requests would linger indefinitely + /// with their payment locked; cleanup is now permissionless and incentive-aligned + /// (the requester gets refunded, the caller pays the gas). + /// Refund is bounded to requests that were never activated AND never rejected. + /// `req.activated` is set inside `_activateService` so an activated request — whose + /// `paymentAmount` has already been routed to operators — cannot be drained again. + function expireServiceRequest(uint64 requestId) external nonReentrant { + Types.ServiceRequest storage req = _getServiceRequest(requestId); + if (req.rejected || req.activated) revert Errors.ServiceRequestAlreadyProcessed(requestId); + + uint64 grace = _requestExpiryGracePeriod; + if (grace == 0) grace = ProtocolConfig.REQUEST_EXPIRY_GRACE_PERIOD; + if (block.timestamp <= uint256(req.createdAt) + grace) { + revert Errors.ServiceRequestNotExpired(requestId); + } + + req.rejected = true; + PaymentLib.refundPayment(req.requester, req.paymentToken, req.paymentAmount); + + emit ServiceRequestExpired(requestId, msg.sender); + } +} diff --git a/src/facets/tangle/TanglePaymentsDistributionFacet.sol b/src/facets/tangle/TanglePaymentsDistributionFacet.sol new file mode 100644 index 00000000..feb53576 --- /dev/null +++ b/src/facets/tangle/TanglePaymentsDistributionFacet.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { PaymentsDistribution } from "../../core/PaymentsDistribution.sol"; +import { Errors } from "../../libraries/Errors.sol"; +import { IFacetSelectors } from "../../interfaces/IFacetSelectors.sol"; +import { ITanglePaymentsInternal } from "../../interfaces/ITanglePaymentsInternal.sol"; + +/// @title TanglePaymentsDistributionFacet +/// @notice Sole owner of the bill distribution path. +/// @dev These selectors are invoked exclusively via `address(this).call(...)` from other +/// facets in the diamond: +/// - `distributePayment` from event-driven job + RFQ + extension flows +/// - `depositToEscrow` from event-driven job flows +/// - `distributeBillWithKeeper` from the subscription billing facet +/// Hosting them on a dedicated facet keeps the billing facet under EIP-170. +contract TanglePaymentsDistributionFacet is PaymentsDistribution, IFacetSelectors { + function selectors() external pure returns (bytes4[] memory selectorList) { + selectorList = new bytes4[](4); + selectorList[0] = this.distributePayment.selector; + selectorList[1] = this.depositToEscrow.selector; + selectorList[2] = this.distributeBillWithKeeper.selector; + selectorList[3] = this.initSubscriptionBaseline.selector; + } + + /// @notice Seed per-operator TWAP cursors and pin the subscription baseline at activation. + /// @dev Self-call only — invoked from `_activateService` and from RFQ quote-create paths. + function initSubscriptionBaseline(uint64 serviceId, address[] calldata operators) external { + if (msg.sender != address(this)) revert Errors.Unauthorized(); + _initSubscriptionBaseline(serviceId, operators); + } + + /// @notice Distribute payment using effective exposures (delegation × exposureBps). + function distributePayment( + uint64 serviceId, + uint64 blueprintId, + address token, + uint256 amount, + address[] calldata operators + ) + external + { + if (msg.sender != address(this)) revert Errors.Unauthorized(); + + (uint256[] memory effectiveExposures, uint256 totalEffectiveExposure, bool hasSecurityCommitments) = + _calculateEffectiveExposuresWithFallback(serviceId, operators); + + _distributePaymentWithEffectiveExposure( + serviceId, + blueprintId, + token, + amount, + operators, + effectiveExposures, + totalEffectiveExposure, + hasSecurityCommitments + ); + } + + function depositToEscrow(uint64 serviceId, address token, uint256 amount) external { + if (msg.sender != address(this)) revert Errors.Unauthorized(); + _depositToEscrow(serviceId, token, amount); + } + + /// @notice Distribute a pre-weighted subscription bill (with optional keeper rebate). + /// @dev Self-call only. The caller (subscription billing facet) is responsible for + /// releasing `d.amount` from escrow BEFORE invoking — this entry point is pure + /// distribution. Weights, operators, and `hasSecurityCommitments` are computed + /// by the caller from TWAP-projected per-(op, asset) cum-stake-seconds. + function distributeBillWithKeeper(ITanglePaymentsInternal.BillDistribution calldata d) external { + if (msg.sender != address(this)) revert Errors.Unauthorized(); + // Re-pack into memory for the internal entry point. The calldata struct already + // matches the in-memory layout used by `_distributeBill`. + ITanglePaymentsInternal.BillDistribution memory m = ITanglePaymentsInternal.BillDistribution({ + serviceId: d.serviceId, + blueprintId: d.blueprintId, + token: d.token, + amount: d.amount, + operators: d.operators, + weights: d.weights, + totalWeight: d.totalWeight, + hasSecurityCommitments: d.hasSecurityCommitments, + keeper: d.keeper + }); + _distributeBill(m); + } +} diff --git a/src/facets/tangle/TanglePaymentsFacet.sol b/src/facets/tangle/TanglePaymentsFacet.sol index c12ae5ca..027cec1d 100644 --- a/src/facets/tangle/TanglePaymentsFacet.sol +++ b/src/facets/tangle/TanglePaymentsFacet.sol @@ -1,78 +1,24 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; -import { Payments } from "../../core/Payments.sol"; -import { Errors } from "../../libraries/Errors.sol"; +import { PaymentsEscrow } from "../../core/PaymentsEscrow.sol"; +import { PaymentsBilling } from "../../core/PaymentsBilling.sol"; import { IFacetSelectors } from "../../interfaces/IFacetSelectors.sol"; /// @title TanglePaymentsFacet -/// @notice Facet for escrow and rewards -/// @dev Implements effective exposure payment distribution for accurate security-weighted payments -contract TanglePaymentsFacet is Payments, IFacetSelectors { +/// @notice Customer-facing escrow funding/withdrawal + subscription billing entry points. +/// @dev Distribution self-call selectors (`distributePayment`, `depositToEscrow`, +/// `initSubscriptionBaseline`, `distributeBillWithKeeper`) live on +/// `TanglePaymentsDistributionFacet`. Rewards claims + admin/views live on +/// `TanglePaymentsRewardsFacet`. Subscription billing reaches the distribution +/// path via a diamond self-call so the heavy distribution machinery does not +/// contribute bytecode to this facet. +contract TanglePaymentsFacet is PaymentsEscrow, PaymentsBilling, IFacetSelectors { function selectors() external pure returns (bytes4[] memory selectorList) { - selectorList = new bytes4[](20); + selectorList = new bytes4[](4); selectorList[0] = this.fundService.selector; selectorList[1] = this.withdrawRemainingEscrow.selector; selectorList[2] = this.billSubscription.selector; selectorList[3] = this.billSubscriptionBatch.selector; - selectorList[4] = this.getBillableServices.selector; - selectorList[5] = bytes4(keccak256("claimRewards()")); - selectorList[6] = bytes4(keccak256("claimRewards(address)")); - selectorList[7] = bytes4(keccak256("claimRewardsBatch(address[])")); - selectorList[8] = bytes4(keccak256("claimRewardsAll()")); - selectorList[9] = bytes4(keccak256("pendingRewards(address)")); - selectorList[10] = bytes4(keccak256("pendingRewards(address,address)")); - selectorList[11] = bytes4(keccak256("rewardTokens(address)")); - selectorList[12] = this.setPaymentSplit.selector; - selectorList[13] = this.setTreasury.selector; - selectorList[14] = this.paymentSplit.selector; - selectorList[15] = this.treasury.selector; - selectorList[16] = this.getServiceEscrow.selector; - selectorList[17] = this.distributePayment.selector; - selectorList[18] = this.depositToEscrow.selector; - selectorList[19] = this.initSubscriptionBaseline.selector; - } - - /// @notice Distribute payment using effective exposures (delegation × exposureBps) - /// @dev Computes effective exposures internally from operator security commitments. - /// Operators always get paid for providing compute. - function distributePayment( - uint64 serviceId, - uint64 blueprintId, - address token, - uint256 amount, - address[] calldata operators - ) - external - { - if (msg.sender != address(this)) revert Errors.Unauthorized(); - - // Compute effective exposures (with fallback to stored exposureBps) - (uint256[] memory effectiveExposures, uint256 totalEffectiveExposure, bool hasSecurityCommitments) = - _calculateEffectiveExposuresWithFallback(serviceId, operators); - - _distributePaymentWithEffectiveExposure( - serviceId, - blueprintId, - token, - amount, - operators, - effectiveExposures, - totalEffectiveExposure, - hasSecurityCommitments - ); - } - - function depositToEscrow(uint64 serviceId, address token, uint256 amount) external { - if (msg.sender != address(this)) revert Errors.Unauthorized(); - _depositToEscrow(serviceId, token, amount); - } - - /// @notice Seed per-operator TWAP cursors and pin the subscription baseline at activation. - /// @dev Self-call gated by `msg.sender == address(this)`. Operators flow through as - /// calldata — no memory copy needed. - function initSubscriptionBaseline(uint64 serviceId, address[] calldata operators) external { - if (msg.sender != address(this)) revert Errors.Unauthorized(); - _initSubscriptionBaseline(serviceId, operators); } } diff --git a/src/facets/tangle/TanglePaymentsRewardsFacet.sol b/src/facets/tangle/TanglePaymentsRewardsFacet.sol new file mode 100644 index 00000000..28989fe0 --- /dev/null +++ b/src/facets/tangle/TanglePaymentsRewardsFacet.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { PaymentsRewards } from "../../core/PaymentsRewards.sol"; +import { IFacetSelectors } from "../../interfaces/IFacetSelectors.sol"; + +/// @title TanglePaymentsRewardsFacet +/// @notice Rewards claim, payment-split / treasury admin, escrow view. +/// @dev Hosted on its own facet so the bytecode footprint stays small. The billing, +/// escrow funding, and distribution selectors live on `TanglePaymentsFacet`. +contract TanglePaymentsRewardsFacet is PaymentsRewards, IFacetSelectors { + function selectors() external pure returns (bytes4[] memory selectorList) { + selectorList = new bytes4[](13); + selectorList[0] = bytes4(keccak256("claimRewards()")); + selectorList[1] = bytes4(keccak256("claimRewards(address)")); + selectorList[2] = bytes4(keccak256("claimRewardsBatch(address[])")); + selectorList[3] = bytes4(keccak256("claimRewardsAll()")); + selectorList[4] = bytes4(keccak256("pendingRewards(address)")); + selectorList[5] = bytes4(keccak256("pendingRewards(address,address)")); + selectorList[6] = bytes4(keccak256("rewardTokens(address)")); + selectorList[7] = this.setPaymentSplit.selector; + selectorList[8] = this.setTreasury.selector; + selectorList[9] = this.paymentSplit.selector; + selectorList[10] = this.treasury.selector; + selectorList[11] = this.getServiceEscrow.selector; + selectorList[12] = this.getBillableServices.selector; + } +} diff --git a/src/facets/tangle/TangleServicesFacet.sol b/src/facets/tangle/TangleServicesFacet.sol index f7ddfaba..68f1d67f 100644 --- a/src/facets/tangle/TangleServicesFacet.sol +++ b/src/facets/tangle/TangleServicesFacet.sol @@ -17,45 +17,9 @@ contract TangleServicesFacet is ServicesApprovals, IFacetSelectors { using EnumerableSet for EnumerableSet.AddressSet; function selectors() external pure returns (bytes4[] memory selectorList) { - selectorList = new bytes4[](7); + selectorList = new bytes4[](2); selectorList[0] = this.approveService.selector; selectorList[1] = this.rejectService.selector; - selectorList[2] = this.getOperatorBlsPubkey.selector; - selectorList[3] = this.blsPopMessage.selector; - selectorList[4] = this.getTeeCommitmentRoot.selector; - selectorList[5] = this.teeNonceFor.selector; - // The aa511c2 release added `expireServiceRequest` to ITangleServices but - // forgot to register it here, so the permissionless cleanup path was - // unreachable on the proxy. - selectorList[6] = this.expireServiceRequest.selector; - } - - /// @notice keccak256 root over an operator's `TeeAttestationCommitment[]` for a service. - /// @dev Slashing / provisioning oracles supply the original array as a witness and verify - /// `keccak256(abi.encode(witness)) == getTeeCommitmentRoot(serviceId, operator)` before - /// treating the witness as authoritative. Returns `bytes32(0)` if the operator - /// approved without TEE commitments. - function getTeeCommitmentRoot(uint64 serviceId, address operator) external view returns (bytes32) { - return _serviceTeeCommitmentRoot[serviceId][operator]; - } - - /// @notice Get operator's BLS public key for a service - /// @param serviceId The service ID - /// @param operator The operator address - /// @return blsPubkey The BLS G2 public key [x0, x1, y0, y1], all zeros if not registered - function getOperatorBlsPubkey( - uint64 serviceId, - address operator - ) - external - view - returns (uint256[4] memory blsPubkey) - { - Types.BLSPubkey storage stored = _serviceOperatorBlsPubkeys[serviceId][operator]; - blsPubkey[0] = stored.key[0]; - blsPubkey[1] = stored.key[1]; - blsPubkey[2] = stored.key[2]; - blsPubkey[3] = stored.key[3]; } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/facets/tangle/TangleServicesViewsFacet.sol b/src/facets/tangle/TangleServicesViewsFacet.sol new file mode 100644 index 00000000..0b21b426 --- /dev/null +++ b/src/facets/tangle/TangleServicesViewsFacet.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { ServicesApprovalsViews } from "../../core/ServicesApprovalsViews.sol"; +import { IFacetSelectors } from "../../interfaces/IFacetSelectors.sol"; + +/// @title TangleServicesViewsFacet +/// @notice Hosts public read-only helpers and the permissionless request-expiry path. +/// @dev Carved off `TangleServicesFacet` so the approve/activate machinery does not have to +/// live alongside these selectors. Inherits only `ServicesApprovalsViews` to keep the +/// compiled facet small. +contract TangleServicesViewsFacet is ServicesApprovalsViews, IFacetSelectors { + function selectors() external pure returns (bytes4[] memory selectorList) { + selectorList = new bytes4[](5); + selectorList[0] = this.getOperatorBlsPubkey.selector; + selectorList[1] = this.blsPopMessage.selector; + selectorList[2] = this.getTeeCommitmentRoot.selector; + selectorList[3] = this.teeNonceFor.selector; + selectorList[4] = this.expireServiceRequest.selector; + } +} diff --git a/src/interfaces/ITanglePaymentsInternal.sol b/src/interfaces/ITanglePaymentsInternal.sol index dfbabe89..e526cd11 100644 --- a/src/interfaces/ITanglePaymentsInternal.sol +++ b/src/interfaces/ITanglePaymentsInternal.sol @@ -2,14 +2,23 @@ pragma solidity ^0.8.26; interface ITanglePaymentsInternal { + /// @notice Pre-computed bill distribution parameters passed across the diamond + /// self-call boundary from the subscription billing facet to the + /// distribution facet. Mirrors the in-memory struct in `PaymentsDistribution`. + struct BillDistribution { + uint64 serviceId; + uint64 blueprintId; + address token; + uint256 amount; + address[] operators; + uint256[] weights; + uint256 totalWeight; + bool hasSecurityCommitments; + address keeper; + } + /// @notice Distribute payment using effective exposures (delegation × exposureBps) /// @dev Computes effective exposures internally from operator security commitments. - /// Operators are paid proportionally to actual security capital at risk. - /// @param serviceId The service ID - /// @param blueprintId The blueprint ID - /// @param token Payment token address - /// @param amount Total payment amount - /// @param operators Array of operator addresses function distributePayment( uint64 serviceId, uint64 blueprintId, @@ -22,8 +31,10 @@ interface ITanglePaymentsInternal { function depositToEscrow(uint64 serviceId, address token, uint256 amount) external; /// @notice Seed per-operator TWAP cursors and pin the subscription baseline at activation. - /// @dev Called for Subscription-pricing services from the activation paths so the first - /// bill measures against the activation snapshot (not against state captured at - /// first bill, which would let post-activation stake changes shift the baseline). function initSubscriptionBaseline(uint64 serviceId, address[] calldata operators) external; + + /// @notice Distribute a pre-weighted subscription bill (with optional keeper rebate). + /// @dev Self-call only. The caller is responsible for releasing `amount` from escrow + /// BEFORE invoking — this entry point is pure distribution. + function distributeBillWithKeeper(BillDistribution calldata d) external; } diff --git a/src/libraries/AttestationLib.sol b/src/libraries/AttestationLib.sol new file mode 100644 index 00000000..9d04d0e5 --- /dev/null +++ b/src/libraries/AttestationLib.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +/// @title AttestationLib +/// @notice Pure helpers for TEE attestation nonces and BLS proof-of-possession messages. +/// @dev Lives in a library so the same compute can be shared between the approvals path +/// (where it is consumed during validation) and the views facet (where it is exposed +/// as a public read) without forcing the approvals contract to inherit the views +/// facet's bytecode. +library AttestationLib { + /// @notice Canonical TEE attestation nonce for `requestId` on `verifyingContract` on `chainId`. + /// @dev Operators MUST set `TeeAttestationCommitment.nonceBinding` to this exact value. + /// Cross-request attestation replay is structurally impossible: an attestation + /// document binding to nonce N_A cannot satisfy a commitment requiring nonce N_B. + function teeNonce( + uint64 requestId, + address verifyingContract, + uint256 chainId + ) + internal + pure + returns (bytes32) + { + return keccak256(abi.encode("tangle.tee.nonce", requestId, verifyingContract, chainId)); + } + + /// @notice Domain-separated message every operator must sign with their BLS secret key + /// to register a public key. Bound to chainId + verifying contract + operator + /// address so a PoP from one chain or operator cannot be replayed. + function blsPopMessage( + address operator, + uint256[4] memory blsPubkey, + address verifyingContract, + uint256 chainId + ) + internal + pure + returns (bytes memory) + { + return abi.encode("TANGLE_BLS_POP_v1", chainId, verifyingContract, operator, blsPubkey); + } +} diff --git a/src/libraries/ServiceValidationLib.sol b/src/libraries/ServiceValidationLib.sol new file mode 100644 index 00000000..72d9fbfb --- /dev/null +++ b/src/libraries/ServiceValidationLib.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { AttestationLib } from "./AttestationLib.sol"; +import { BN254 } from "./BN254.sol"; +import { Errors } from "./Errors.sol"; +import { Types } from "./Types.sol"; + +library ServiceValidationLib { + uint256 internal constant MAX_TEE_COMMITMENTS_PER_OPERATOR = 8; + uint64 internal constant MAX_TEE_COMMITMENT_TTL = 90 days; + + function validateTeeCommitments( + uint64, + Types.TeeAttestationCommitment[] calldata teeCommitments, + bytes32 expectedNonce + ) + external + view + { + if (teeCommitments.length > MAX_TEE_COMMITMENTS_PER_OPERATOR) { + revert Errors.TooManyTeeCommitments(teeCommitments.length, MAX_TEE_COMMITMENTS_PER_OPERATOR); + } + uint64 nowTs = uint64(block.timestamp); + uint64 maxExpiresAt = nowTs + MAX_TEE_COMMITMENT_TTL; + for (uint256 i = 0; i < teeCommitments.length; i++) { + Types.TeeBackend backend = teeCommitments[i].backend; + if (backend == Types.TeeBackend.Unset) revert Errors.UnsetTeeBackend(); + if (backend == Types.TeeBackend.DirectTdx) revert Errors.DirectTdxNotPermitted(); + if (teeCommitments[i].nonceBinding != expectedNonce) revert Errors.InvalidNonceBinding(); + if (teeCommitments[i].expectedMeasurement == bytes32(0)) revert Errors.InvalidExpectedMeasurement(); + uint64 expiresAt = teeCommitments[i].expiresAt; + if (expiresAt != 0) { + if (expiresAt <= nowTs) revert Errors.TeeCommitmentExpired(expiresAt, nowTs); + if (expiresAt > maxExpiresAt) revert Errors.TeeCommitmentExpiryTooFar(expiresAt, maxExpiresAt); + } + } + } + + function validateSecurityCommitments( + Types.AssetSecurityRequirement[] storage requirements, + Types.AssetSecurityCommitment[] calldata commitments + ) + external + view + { + for (uint256 i = 0; i < commitments.length; i++) { + for (uint256 j = i + 1; j < commitments.length; j++) { + if ( + commitments[i].asset.token == commitments[j].asset.token + && commitments[i].asset.kind == commitments[j].asset.kind + ) { + revert Errors.DuplicateAssetCommitment(uint8(commitments[i].asset.kind), commitments[i].asset.token); + } + } + } + + for (uint256 i = 0; i < requirements.length; i++) { + Types.AssetSecurityRequirement storage req = requirements[i]; + bool found = false; + + for (uint256 j = 0; j < commitments.length; j++) { + if (commitments[j].asset.token == req.asset.token && commitments[j].asset.kind == req.asset.kind) { + if (commitments[j].exposureBps < req.minExposureBps) { + revert Errors.CommitmentBelowMinimum( + req.asset.token, commitments[j].exposureBps, req.minExposureBps + ); + } + if (commitments[j].exposureBps > req.maxExposureBps) { + revert Errors.CommitmentAboveMaximum( + req.asset.token, commitments[j].exposureBps, req.maxExposureBps + ); + } + found = true; + break; + } + } + + if (!found) revert Errors.MissingAssetCommitment(req.asset.token); + } + } + + function requireBlsProofOfPossession( + address operator, + uint256[4] memory blsPubkey, + uint256[2] memory popSignature, + address verifyingContract, + uint256 chainId + ) + external + view + { + bool ok = BN254.verifyBls( + AttestationLib.blsPopMessage(operator, blsPubkey, verifyingContract, chainId), + Types.BN254G1Point({ x: popSignature[0], y: popSignature[1] }), + Types.BN254G2Point({ x: [blsPubkey[0], blsPubkey[1]], y: [blsPubkey[2], blsPubkey[3]] }) + ); + if (!ok) revert Errors.InvalidBLSSignature(); + } +} diff --git a/test/BaseTest.sol b/test/BaseTest.sol index e610b5f1..e4bddffa 100644 --- a/test/BaseTest.sol +++ b/test/BaseTest.sol @@ -18,12 +18,15 @@ import { TangleBlueprintsManagementFacet } from "../src/facets/tangle/TangleBlue import { TangleOperatorsFacet } from "../src/facets/tangle/TangleOperatorsFacet.sol"; import { TangleServicesRequestsFacet } from "../src/facets/tangle/TangleServicesRequestsFacet.sol"; import { TangleServicesFacet } from "../src/facets/tangle/TangleServicesFacet.sol"; +import { TangleServicesViewsFacet } from "../src/facets/tangle/TangleServicesViewsFacet.sol"; import { TangleServicesLifecycleFacet } from "../src/facets/tangle/TangleServicesLifecycleFacet.sol"; import { TangleJobsFacet } from "../src/facets/tangle/TangleJobsFacet.sol"; import { TangleJobsAggregationFacet } from "../src/facets/tangle/TangleJobsAggregationFacet.sol"; import { TangleQuotesFacet } from "../src/facets/tangle/TangleQuotesFacet.sol"; import { TangleQuotesExtensionFacet } from "../src/facets/tangle/TangleQuotesExtensionFacet.sol"; import { TanglePaymentsFacet } from "../src/facets/tangle/TanglePaymentsFacet.sol"; +import { TanglePaymentsRewardsFacet } from "../src/facets/tangle/TanglePaymentsRewardsFacet.sol"; +import { TanglePaymentsDistributionFacet } from "../src/facets/tangle/TanglePaymentsDistributionFacet.sol"; import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.sol"; import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; @@ -181,12 +184,15 @@ abstract contract BaseTest is Test, BlueprintDefinitionHelper { router.registerFacet(address(new TangleOperatorsFacet())); router.registerFacet(address(new TangleServicesRequestsFacet())); router.registerFacet(address(new TangleServicesFacet())); + router.registerFacet(address(new TangleServicesViewsFacet())); router.registerFacet(address(new TangleServicesLifecycleFacet())); router.registerFacet(address(new TangleJobsFacet())); router.registerFacet(address(new TangleJobsAggregationFacet())); router.registerFacet(address(new TangleQuotesFacet())); router.registerFacet(address(new TangleQuotesExtensionFacet())); router.registerFacet(address(new TanglePaymentsFacet())); + router.registerFacet(address(new TanglePaymentsRewardsFacet())); + router.registerFacet(address(new TanglePaymentsDistributionFacet())); router.registerFacet(address(new TangleSlashingFacet())); } diff --git a/test/blueprints/TestHarness.sol b/test/blueprints/TestHarness.sol index c44c64a3..8416a844 100644 --- a/test/blueprints/TestHarness.sol +++ b/test/blueprints/TestHarness.sol @@ -17,12 +17,15 @@ import { TangleBlueprintsManagementFacet } from "../../src/facets/tangle/TangleB import { TangleOperatorsFacet } from "../../src/facets/tangle/TangleOperatorsFacet.sol"; import { TangleServicesRequestsFacet } from "../../src/facets/tangle/TangleServicesRequestsFacet.sol"; import { TangleServicesFacet } from "../../src/facets/tangle/TangleServicesFacet.sol"; +import { TangleServicesViewsFacet } from "../../src/facets/tangle/TangleServicesViewsFacet.sol"; import { TangleServicesLifecycleFacet } from "../../src/facets/tangle/TangleServicesLifecycleFacet.sol"; import { TangleJobsFacet } from "../../src/facets/tangle/TangleJobsFacet.sol"; import { TangleJobsAggregationFacet } from "../../src/facets/tangle/TangleJobsAggregationFacet.sol"; import { TangleQuotesFacet } from "../../src/facets/tangle/TangleQuotesFacet.sol"; import { TangleQuotesExtensionFacet } from "../../src/facets/tangle/TangleQuotesExtensionFacet.sol"; import { TanglePaymentsFacet } from "../../src/facets/tangle/TanglePaymentsFacet.sol"; +import { TanglePaymentsRewardsFacet } from "../../src/facets/tangle/TanglePaymentsRewardsFacet.sol"; +import { TanglePaymentsDistributionFacet } from "../../src/facets/tangle/TanglePaymentsDistributionFacet.sol"; import { TangleSlashingFacet } from "../../src/facets/tangle/TangleSlashingFacet.sol"; import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; @@ -153,12 +156,15 @@ abstract contract BlueprintTestHarness is Test, BlueprintDefinitionHelper { router.registerFacet(address(new TangleOperatorsFacet())); router.registerFacet(address(new TangleServicesRequestsFacet())); router.registerFacet(address(new TangleServicesFacet())); + router.registerFacet(address(new TangleServicesViewsFacet())); router.registerFacet(address(new TangleServicesLifecycleFacet())); router.registerFacet(address(new TangleJobsFacet())); router.registerFacet(address(new TangleJobsAggregationFacet())); router.registerFacet(address(new TangleQuotesFacet())); router.registerFacet(address(new TangleQuotesExtensionFacet())); router.registerFacet(address(new TanglePaymentsFacet())); + router.registerFacet(address(new TanglePaymentsRewardsFacet())); + router.registerFacet(address(new TanglePaymentsDistributionFacet())); router.registerFacet(address(new TangleSlashingFacet())); } diff --git a/test/fuzz/InvariantFuzz.t.sol b/test/fuzz/InvariantFuzz.t.sol index 5321e48e..ad642c42 100644 --- a/test/fuzz/InvariantFuzz.t.sol +++ b/test/fuzz/InvariantFuzz.t.sol @@ -20,12 +20,15 @@ import { TangleBlueprintsManagementFacet } from "../../src/facets/tangle/TangleB import { TangleOperatorsFacet } from "../../src/facets/tangle/TangleOperatorsFacet.sol"; import { TangleServicesRequestsFacet } from "../../src/facets/tangle/TangleServicesRequestsFacet.sol"; import { TangleServicesFacet } from "../../src/facets/tangle/TangleServicesFacet.sol"; +import { TangleServicesViewsFacet } from "../../src/facets/tangle/TangleServicesViewsFacet.sol"; import { TangleServicesLifecycleFacet } from "../../src/facets/tangle/TangleServicesLifecycleFacet.sol"; import { TangleJobsFacet } from "../../src/facets/tangle/TangleJobsFacet.sol"; import { TangleJobsAggregationFacet } from "../../src/facets/tangle/TangleJobsAggregationFacet.sol"; import { TangleQuotesFacet } from "../../src/facets/tangle/TangleQuotesFacet.sol"; import { TangleQuotesExtensionFacet } from "../../src/facets/tangle/TangleQuotesExtensionFacet.sol"; import { TanglePaymentsFacet } from "../../src/facets/tangle/TanglePaymentsFacet.sol"; +import { TanglePaymentsRewardsFacet } from "../../src/facets/tangle/TanglePaymentsRewardsFacet.sol"; +import { TanglePaymentsDistributionFacet } from "../../src/facets/tangle/TanglePaymentsDistributionFacet.sol"; import { TangleSlashingFacet } from "../../src/facets/tangle/TangleSlashingFacet.sol"; import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; @@ -547,12 +550,15 @@ contract InvariantFuzzTest is Test, BlueprintDefinitionHelper { router.registerFacet(address(new TangleOperatorsFacet())); router.registerFacet(address(new TangleServicesRequestsFacet())); router.registerFacet(address(new TangleServicesFacet())); + router.registerFacet(address(new TangleServicesViewsFacet())); router.registerFacet(address(new TangleServicesLifecycleFacet())); router.registerFacet(address(new TangleJobsFacet())); router.registerFacet(address(new TangleJobsAggregationFacet())); router.registerFacet(address(new TangleQuotesFacet())); router.registerFacet(address(new TangleQuotesExtensionFacet())); router.registerFacet(address(new TanglePaymentsFacet())); + router.registerFacet(address(new TanglePaymentsRewardsFacet())); + router.registerFacet(address(new TanglePaymentsDistributionFacet())); router.registerFacet(address(new TangleSlashingFacet())); }