|
2 | 2 | pragma solidity ^0.8.27; |
3 | 3 |
|
4 | 4 | import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; |
| 5 | +import { OFFER_TYPE_NEW } from "@graphprotocol/interfaces/contracts/horizon/IAgreementCollector.sol"; |
5 | 6 |
|
6 | 7 | import { RecurringCollectorSharedTest } from "./shared.t.sol"; |
7 | 8 |
|
@@ -65,5 +66,171 @@ contract RecurringCollectorAcceptTest is RecurringCollectorSharedTest { |
65 | 66 | assertEq(vm.getRecordedLogs().length, 0, "no event emitted on idempotent re-accept"); |
66 | 67 | } |
67 | 68 |
|
| 69 | + /// @notice Re-accepting an already-accepted RCA at the same hash must still succeed after |
| 70 | + /// the RCA's acceptance deadline has elapsed. The idempotent short-circuit runs before the |
| 71 | + /// deadline check so signature lifetime is not consumed — this is the path the SubgraphService |
| 72 | + /// relies on to rebind an agreement to a new allocation after the original acceptance window |
| 73 | + /// has closed. |
| 74 | + function test_Accept_Idempotent_AfterDeadline_SameHash(FuzzyTestAccept calldata fuzzyTestAccept) public { |
| 75 | + ( |
| 76 | + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, |
| 77 | + bytes memory signature, |
| 78 | + , |
| 79 | + bytes16 agreementId |
| 80 | + ) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); |
| 81 | + |
| 82 | + // Warp past the RCA's deadline — a fresh accept would now revert with |
| 83 | + // RecurringCollectorAgreementDeadlineElapsed. |
| 84 | + vm.warp(uint256(acceptedRca.deadline) + 1); |
| 85 | + |
| 86 | + vm.recordLogs(); |
| 87 | + vm.prank(acceptedRca.dataService); |
| 88 | + bytes16 returnedId = _recurringCollector.accept(acceptedRca, signature); |
| 89 | + assertEq(returnedId, agreementId, "returns the same agreementId"); |
| 90 | + assertEq(vm.getRecordedLogs().length, 0, "no event emitted on idempotent re-accept after deadline"); |
| 91 | + |
| 92 | + // Sanity: the collector-side agreement is still in Accepted state, unchanged by the no-op. |
| 93 | + IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(agreementId); |
| 94 | + assertEq(uint8(agreement.state), uint8(IRecurringCollector.AgreementState.Accepted)); |
| 95 | + } |
| 96 | + |
| 97 | + /// @notice A fresh accept (no prior offer()) stores terms via _validateAndStoreTerms, which must |
| 98 | + /// emit OfferStored. AgreementAccepted follows. Both events observable in order. |
| 99 | + function test_Accept_EmitsOfferStored_WhenFreshTerms(FuzzyTestAccept calldata fuzzyTestAccept) public { |
| 100 | + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( |
| 101 | + fuzzyTestAccept.rca |
| 102 | + ); |
| 103 | + uint256 signerKey = boundKey(fuzzyTestAccept.unboundedSignerKey); |
| 104 | + _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, signerKey); |
| 105 | + (, bytes memory signature) = _recurringCollectorHelper.generateSignedRCA(rca, signerKey); |
| 106 | + bytes32 rcaHash = _recurringCollector.hashRCA(rca); |
| 107 | + bytes16 agreementId = _recurringCollector.generateAgreementId( |
| 108 | + rca.payer, |
| 109 | + rca.dataService, |
| 110 | + rca.serviceProvider, |
| 111 | + rca.deadline, |
| 112 | + rca.nonce |
| 113 | + ); |
| 114 | + _setupValidProvision(rca.serviceProvider, rca.dataService); |
| 115 | + |
| 116 | + // OfferStored fires from _validateAndStoreTerms before _storeAgreement; AgreementAccepted |
| 117 | + // follows the state transition at the end of accept(). |
| 118 | + vm.expectEmit(address(_recurringCollector)); |
| 119 | + emit IRecurringCollector.OfferStored(agreementId, rca.payer, OFFER_TYPE_NEW, rcaHash); |
| 120 | + vm.expectEmit(address(_recurringCollector)); |
| 121 | + emit IRecurringCollector.AgreementAccepted( |
| 122 | + rca.dataService, |
| 123 | + rca.payer, |
| 124 | + rca.serviceProvider, |
| 125 | + agreementId, |
| 126 | + rca.endsAt, |
| 127 | + rca.maxInitialTokens, |
| 128 | + rca.maxOngoingTokensPerSecond, |
| 129 | + rca.minSecondsPerCollection, |
| 130 | + rca.maxSecondsPerCollection |
| 131 | + ); |
| 132 | + vm.prank(rca.dataService); |
| 133 | + _recurringCollector.accept(rca, signature); |
| 134 | + } |
| 135 | + |
| 136 | + /// @notice A second RCA sharing the same agreementId seed (payer, dataService, serviceProvider, |
| 137 | + /// deadline, nonce) but with different other fields — so different rcaHash — must not be |
| 138 | + /// accepted against an already-Accepted agreement. The idempotent short-circuit only fires on |
| 139 | + /// exact hash match; everything else must fall through to the state guard and revert. Proves |
| 140 | + /// the short-circuit can't be abused as an overwrite path even in an imagined 128-bit |
| 141 | + /// agreementId collision. |
| 142 | + function test_Accept_Revert_WhenDifferentHashSameAgreementId(FuzzyTestAccept calldata fuzzyTestAccept) public { |
| 143 | + ( |
| 144 | + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, |
| 145 | + , |
| 146 | + uint256 signerKey, |
| 147 | + bytes16 agreementId |
| 148 | + ) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); |
| 149 | + |
| 150 | + // Snapshot the original hash before constructing the variant. `variant = acceptedRca` in |
| 151 | + // Solidity memory is a reference copy, so rebuild explicitly to vary one pricing field |
| 152 | + // while keeping the 5 agreementId-seed fields (payer, dataService, serviceProvider, |
| 153 | + // deadline, nonce) verbatim. |
| 154 | + bytes32 originalHash = _recurringCollector.hashRCA(acceptedRca); |
| 155 | + IRecurringCollector.RecurringCollectionAgreement memory variant = IRecurringCollector |
| 156 | + .RecurringCollectionAgreement({ |
| 157 | + deadline: acceptedRca.deadline, |
| 158 | + endsAt: acceptedRca.endsAt, |
| 159 | + payer: acceptedRca.payer, |
| 160 | + dataService: acceptedRca.dataService, |
| 161 | + serviceProvider: acceptedRca.serviceProvider, |
| 162 | + maxInitialTokens: acceptedRca.maxInitialTokens + 1, // <-- vary |
| 163 | + maxOngoingTokensPerSecond: acceptedRca.maxOngoingTokensPerSecond, |
| 164 | + minSecondsPerCollection: acceptedRca.minSecondsPerCollection, |
| 165 | + maxSecondsPerCollection: acceptedRca.maxSecondsPerCollection, |
| 166 | + conditions: acceptedRca.conditions, |
| 167 | + nonce: acceptedRca.nonce, |
| 168 | + metadata: acceptedRca.metadata |
| 169 | + }); |
| 170 | + |
| 171 | + bytes32 variantHash = _recurringCollector.hashRCA(variant); |
| 172 | + assertTrue(originalHash != variantHash, "hashes must differ when any field differs"); |
| 173 | + assertEq( |
| 174 | + _recurringCollector.generateAgreementId( |
| 175 | + variant.payer, |
| 176 | + variant.dataService, |
| 177 | + variant.serviceProvider, |
| 178 | + variant.deadline, |
| 179 | + variant.nonce |
| 180 | + ), |
| 181 | + agreementId, |
| 182 | + "same agreementId seed yields same id" |
| 183 | + ); |
| 184 | + |
| 185 | + (, bytes memory variantSig) = _recurringCollectorHelper.generateSignedRCA(variant, signerKey); |
| 186 | + |
| 187 | + // Short-circuit doesn't fire (hash differs); falls through to _storeAgreement's state guard. |
| 188 | + vm.expectRevert( |
| 189 | + abi.encodeWithSelector( |
| 190 | + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, |
| 191 | + agreementId, |
| 192 | + IRecurringCollector.AgreementState.Accepted |
| 193 | + ) |
| 194 | + ); |
| 195 | + vm.prank(acceptedRca.dataService); |
| 196 | + _recurringCollector.accept(variant, variantSig); |
| 197 | + |
| 198 | + // Post-revert sanity: storage reflects the original, not the variant. |
| 199 | + IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(agreementId); |
| 200 | + assertEq(agreement.activeTermsHash, originalHash, "activeTermsHash unchanged"); |
| 201 | + } |
| 202 | + |
| 203 | + /// @notice After a cancellation, re-accepting the same RCA at the same hash must revert — the |
| 204 | + /// short-circuit only fires when state == Accepted, so a cancelled agreement falls through to |
| 205 | + /// the NotAccepted state guard. Proves cancelled is terminal and the short-circuit cannot |
| 206 | + /// resurrect it. |
| 207 | + function test_Accept_Revert_AfterCancellation_SameHash(FuzzyTestAccept calldata fuzzyTestAccept) public { |
| 208 | + ( |
| 209 | + IRecurringCollector.RecurringCollectionAgreement memory acceptedRca, |
| 210 | + bytes memory signature, |
| 211 | + , |
| 212 | + bytes16 agreementId |
| 213 | + ) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); |
| 214 | + |
| 215 | + vm.prank(acceptedRca.dataService); |
| 216 | + _recurringCollector.cancel(agreementId, IRecurringCollector.CancelAgreementBy.ServiceProvider); |
| 217 | + |
| 218 | + assertEq( |
| 219 | + uint8(_recurringCollector.getAgreement(agreementId).state), |
| 220 | + uint8(IRecurringCollector.AgreementState.CanceledByServiceProvider), |
| 221 | + "precondition: cancelled" |
| 222 | + ); |
| 223 | + |
| 224 | + vm.expectRevert( |
| 225 | + abi.encodeWithSelector( |
| 226 | + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, |
| 227 | + agreementId, |
| 228 | + IRecurringCollector.AgreementState.CanceledByServiceProvider |
| 229 | + ) |
| 230 | + ); |
| 231 | + vm.prank(acceptedRca.dataService); |
| 232 | + _recurringCollector.accept(acceptedRca, signature); |
| 233 | + } |
| 234 | + |
68 | 235 | /* solhint-enable graph/func-name-mixedcase */ |
69 | 236 | } |
0 commit comments