Skip to content

Commit 05e4869

Browse files
authored
BM-246: Add support for fulfill without lockin (github#94)
Adds a path to fulfill an order without lockin.
1 parent a0ec0fb commit 05e4869

File tree

4 files changed

+216
-26
lines changed

4 files changed

+216
-26
lines changed

contracts/src/IProofMarket.sol

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,25 @@ interface IProofMarket {
195195
/// @notice Delivers a batch of proofs. See IProofMarket.deliver for more information.
196196
function deliverBatch(Fulfillment[] calldata fills, bytes calldata assessorSeal, address prover) external;
197197

198+
/// @notice Checks the validity of the request and then writes the current auction price to
199+
/// transient storage.
200+
/// @dev When called within the same transaction, this method can be used to fulfill a request
201+
/// that is not locked. This is useful when the prover wishes to fulfill a request, but does
202+
/// not want to issue a lock transaction e.g. because the stake is to high or to save money by
203+
/// avoiding the gas costs of the lock transaction.
204+
function priceRequest(ProvingRequest calldata request, bytes calldata clientSignature) external;
205+
206+
/// @notice A combined call to `IProofMarket.priceRequest` and `IProofMarket.fulfillBatch`.
207+
/// The caller should provide the signed request and signature for each unlocked request they
208+
/// want to fulfill. Payment for unlocked requests will go to the provided `prover` address.
209+
function priceAndFulfillBatch(
210+
ProvingRequest[] calldata requests,
211+
bytes[] calldata clientSignatures,
212+
Fulfillment[] calldata fills,
213+
bytes calldata assessorSeal,
214+
address prover
215+
) external;
216+
198217
/// @notice Combined function to submit a new merkle root to the set-verifier and call fulfillBatch.
199218
/// @dev Useful to reduce the transaction count for fulfillments
200219
function submitRootAndFulfillBatch(

contracts/src/ProofMarket.sol

Lines changed: 124 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// All rights reserved.
44

5-
pragma solidity ^0.8.20;
5+
pragma solidity ^0.8.24;
66

77
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
88
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
@@ -97,12 +97,32 @@ struct RequestLock {
9797
uint96 stake;
9898
}
9999

100+
/// Struct encoding the validated price for a request, intended for use with transient storage.
101+
struct TransientPrice {
102+
/// Boolean set to true to indicate the request was validated.
103+
bool valid;
104+
uint96 price;
105+
}
106+
107+
library TransientPriceLib {
108+
/// Packs the struct into a uint256.
109+
function pack(TransientPrice memory x) internal pure returns (uint256) {
110+
return (uint256(x.valid ? 1 : 0) << 96) | uint256(x.price);
111+
}
112+
113+
/// Unpacks the struct from a uint256.
114+
function unpack(uint256 packed) internal pure returns (TransientPrice memory) {
115+
return TransientPrice({valid: (packed & (1 << 96)) > 0, price: uint96(packed & uint256(type(uint96).max))});
116+
}
117+
}
118+
100119
contract ProofMarket is IProofMarket, EIP712 {
101120
using AccountLib for Account;
102121
using ProofMarketLib for Offer;
103122
using ProofMarketLib for ProvingRequest;
104123
using ReceiptClaimLib for ReceiptClaim;
105124
using SafeCast for uint256;
125+
using TransientPriceLib for TransientPrice;
106126

107127
// Mapping of request ID to lock-in state. Non-zero for requests that are locked in.
108128
mapping(uint192 => RequestLock) public requestLocks;
@@ -196,13 +216,19 @@ contract ProofMarket is IProofMarket, EIP712 {
196216
_lockinAuthed(request, client, idx, prover);
197217
}
198218

199-
function _lockinAuthed(ProvingRequest calldata request, address client, uint32 idx, address prover) internal {
219+
/// Check that the request is valid, and not already locked or fulfilled by another prover.
220+
/// Returns the auction price and deadline for the request.
221+
function _validateRequestForLockin(ProvingRequest calldata request, address client, uint32 idx)
222+
internal
223+
view
224+
returns (uint96 price, uint64 deadline)
225+
{
200226
// Check that the request is internally consistent and is not expired.
201227
request.offer.requireValid();
202228

203229
// We are ending the reverse Dutch auction at the current price.
204-
uint96 price = request.offer.priceAtBlock(uint64(block.number));
205-
uint64 deadline = request.offer.deadline();
230+
price = request.offer.priceAtBlock(uint64(block.number));
231+
deadline = request.offer.deadline();
206232
if (deadline < block.number) {
207233
revert RequestIsExpired({requestId: request.id, deadline: deadline});
208234
}
@@ -216,6 +242,12 @@ contract ProofMarket is IProofMarket, EIP712 {
216242
revert RequestIsFulfilled({requestId: request.id});
217243
}
218244

245+
return (price, deadline);
246+
}
247+
248+
function _lockinAuthed(ProvingRequest calldata request, address client, uint32 idx, address prover) internal {
249+
(uint96 price, uint64 deadline) = _validateRequestForLockin(request, client, idx);
250+
219251
// Lock the request such that only the given prover can fulfill it (or else face a penalty).
220252
Account storage clientAccount = accounts[client];
221253
clientAccount.setRequestLocked(idx);
@@ -236,19 +268,39 @@ contract ProofMarket is IProofMarket, EIP712 {
236268
clientAccount.balance -= price;
237269
proverAccount.balance -= request.offer.lockinStake;
238270
}
239-
accounts[client] = clientAccount;
240271

241272
emit RequestLockedin(request.id, prover);
242273
}
243274

275+
/// Validates the request and records the price to transient storage such that it can be
276+
/// fulfilled within the same transaction without taking a lock on it.
277+
function priceRequest(ProvingRequest calldata request, bytes calldata clientSignature) public {
278+
(address client, uint32 idx) = (ProofMarketLib.requestFrom(request.id), ProofMarketLib.requestIndex(request.id));
279+
280+
// Recover the prover address and require the client address to equal the address part of the ID.
281+
bytes32 structHash = _hashTypedDataV4(request.eip712Digest());
282+
require(ECDSA.recover(structHash, clientSignature) == client, "Invalid client signature");
283+
284+
(uint96 price,) = _validateRequestForLockin(request, client, idx);
285+
uint192 requestId = request.id;
286+
287+
// Record the price in transient storage, such that the order can be filled in this same transaction.
288+
// NOTE: Since transient storage is cleared at the end of the transaction, we know that this
289+
// price will not become stale, and the request cannot expire, while this price is recorded.
290+
// TODO(#165): Also record a requirements checksum here when solving #165.
291+
uint256 packed = TransientPrice({valid: true, price: price}).pack();
292+
assembly {
293+
tstore(requestId, packed)
294+
}
295+
}
296+
244297
/// Verify the application and assessor receipts, ensuring that the provided fulfillment
245298
/// satisfies the request.
246299
// TODO(#165) Return or check the request checksum here.
247300
function verifyDelivery(Fulfillment calldata fill, bytes calldata assessorSeal, address prover) public view {
248-
// Verify the application guest proof. We need to verify it here, even though the market
249-
// guest already verified that the prover has knowledge of a verifying receipt, because
250-
// we need to make sure the _delivered_ seal is valid.
251-
// TODO(victor): Support journals hashed with keccak instead of SHA-256.
301+
// Verify the application guest proof. We need to verify it here, even though the assesor
302+
// already verified that the prover has knowledge of a verifying receipt, because we need to
303+
// make sure the _delivered_ seal is valid.
252304
bytes32 claimDigest = ReceiptClaimLib.ok(fill.imageId, sha256(fill.journal)).digest();
253305
VERIFIER.verifyIntegrity{gas: FULFILL_MAX_GAS_FOR_VERIFY}(Receipt(fill.seal, claimDigest));
254306

@@ -304,7 +356,7 @@ contract ProofMarket is IProofMarket, EIP712 {
304356

305357
function fulfill(Fulfillment calldata fill, bytes calldata assessorSeal, address prover) external {
306358
verifyDelivery(fill, assessorSeal, prover);
307-
_fulfillVerified(fill.id);
359+
_fulfillVerified(fill.id, prover);
308360

309361
// TODO(victor): Potentially this should be (re)combined with RequestFulfilled. It would make
310362
// the logic to watch for a proof a bit more complex, but the gas usage a little less (by
@@ -319,40 +371,88 @@ contract ProofMarket is IProofMarket, EIP712 {
319371
// batch update to storage. However, updating the the same storage slot twice only costs 100 gas, so
320372
// this savings is marginal, and will be outweighed by complicated memory management if not careful.
321373
for (uint256 i = 0; i < fills.length; i++) {
322-
_fulfillVerified(fills[i].id);
374+
_fulfillVerified(fills[i].id, prover);
323375

324376
emit ProofDelivered(fills[i].id, fills[i].journal, fills[i].seal);
325377
}
326378
}
327379

380+
function priceAndFulfillBatch(
381+
ProvingRequest[] calldata requests,
382+
bytes[] calldata clientSignatures,
383+
Fulfillment[] calldata fills,
384+
bytes calldata assessorSeal,
385+
address prover
386+
) external {
387+
for (uint256 i = 0; i < requests.length; i++) {
388+
priceRequest(requests[i], clientSignatures[i]);
389+
}
390+
fulfillBatch(fills, assessorSeal, prover);
391+
}
392+
328393
/// Complete the fulfillment logic after having verified the app and assessor receipts.
329-
function _fulfillVerified(uint192 id) internal {
394+
function _fulfillVerified(uint192 id, address assesorProver) internal {
330395
address client = ProofMarketLib.requestFrom(id);
331396
uint32 idx = ProofMarketLib.requestIndex(id);
332397

333-
// Check that the request is not locked to a different prover.
398+
// Check that the request is not fulfilled.
334399
(bool locked, bool fulfilled) = accounts[client].requestFlags(idx);
335400

336-
// Ensure the request is locked, and fetch the lock.
337-
if (!locked) {
338-
revert RequestIsNotLocked({requestId: id});
339-
}
340401
if (fulfilled) {
341402
revert RequestIsFulfilled({requestId: id});
342403
}
343404

344-
RequestLock memory lock = requestLocks[id];
405+
address prover;
406+
uint96 price;
407+
uint96 stake;
408+
if (locked) {
409+
RequestLock memory lock = requestLocks[id];
410+
411+
if (lock.deadline < block.number) {
412+
revert RequestIsExpired({requestId: id, deadline: lock.deadline});
413+
}
414+
415+
prover = lock.prover;
416+
price = lock.price;
417+
stake = lock.stake;
418+
} else {
419+
uint256 packed;
420+
assembly {
421+
packed := tload(id)
422+
}
423+
TransientPrice memory tprice = TransientPriceLib.unpack(packed);
345424

346-
if (lock.deadline < block.number) {
347-
revert RequestIsExpired({requestId: id, deadline: lock.deadline});
425+
// Check that a price has actually been set, rather than this being default.
426+
// NOTE: Maybe "request is not locked or priced" would be more accurate, but seems
427+
// like that would be a confusing message.
428+
if (!tprice.valid) {
429+
revert RequestIsNotLocked({requestId: id});
430+
}
431+
432+
prover = assesorProver;
433+
price = tprice.price;
434+
stake = 0;
348435
}
349436

350-
// Zero out the lock to get a bit of a refund on gas.
351-
requestLocks[id] = RequestLock(address(0), uint96(0), uint64(0), uint96(0));
437+
if (locked) {
438+
// Zero-out the lock to get a bit of a refund on gas.
439+
requestLocks[id] = RequestLock(address(0), uint96(0), uint64(0), uint96(0));
440+
}
441+
442+
Account storage clientAccount = accounts[client];
443+
if (!locked) {
444+
// Deduct the funds from client account.
445+
if (clientAccount.balance < price) {
446+
revert InsufficientBalance(client);
447+
}
448+
unchecked {
449+
clientAccount.balance -= price;
450+
}
451+
}
352452

353453
// Mark the request as fulfilled and pay the prover.
354-
accounts[client].setRequestFulfilled(idx);
355-
accounts[lock.prover].balance += lock.price + lock.stake;
454+
clientAccount.setRequestFulfilled(idx);
455+
accounts[prover].balance += price + stake;
356456

357457
emit RequestFulfilled(id);
358458
}

contracts/test/ProofMarket.t.sol

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {TestReceipt} from "risc0/../test/TestReceipt.sol";
1313
import {RiscZeroMockVerifier} from "risc0/test/RiscZeroMockVerifier.sol";
1414
import {TestUtils} from "./TestUtils.sol";
1515

16-
import {ProofMarket, MerkleProofish, AssessorJournal} from "../src/ProofMarket.sol";
16+
import {ProofMarket, MerkleProofish, AssessorJournal, TransientPrice, TransientPriceLib} from "../src/ProofMarket.sol";
1717
import {
1818
Fulfillment,
1919
IProofMarket,
@@ -603,6 +603,41 @@ contract ProofMarketTest is Test {
603603
proofMarket.fulfill(fill, assessorSeal, mockOtherProverAddr);
604604
}
605605

606+
function testPriceAndFulfill() external {
607+
Vm.Wallet memory client = createClient(1);
608+
609+
ProvingRequest memory request = defaultRequest(client.addr, 3);
610+
611+
bytes memory clientSignature = signRequest(client, request);
612+
613+
uint256 balanceBefore = proofMarket.balanceOf(PROVER_WALLET.addr);
614+
console2.log("Prover balance before:", balanceBefore);
615+
616+
(Fulfillment memory fill, bytes memory assessorSeal) = fulfillRequest(request, APP_JOURNAL, PROVER_WALLET.addr);
617+
618+
Fulfillment[] memory fills = new Fulfillment[](1);
619+
fills[0] = fill;
620+
ProvingRequest[] memory requests = new ProvingRequest[](1);
621+
requests[0] = request;
622+
bytes[] memory clientSignatures = new bytes[](1);
623+
clientSignatures[0] = clientSignature;
624+
625+
vm.expectEmit(true, true, true, true);
626+
emit IProofMarket.RequestFulfilled(request.id);
627+
vm.expectEmit(true, true, true, false);
628+
emit IProofMarket.ProofDelivered(request.id, hex"", hex"");
629+
proofMarket.priceAndFulfillBatch(requests, clientSignatures, fills, assessorSeal, PROVER_WALLET.addr);
630+
631+
// Check that the proof was submitted
632+
assertTrue(proofMarket.requestIsFulfilled(fill.id), "Request should have fulfilled status");
633+
634+
uint256 balanceAfter = proofMarket.balanceOf(PROVER_WALLET.addr);
635+
console2.log("Prover balance after:", balanceAfter);
636+
assertEq(balanceBefore + 1 ether, balanceAfter);
637+
638+
checkProofMarketBalance();
639+
}
640+
606641
function testFulfillAlreadyFulfilled() public {
607642
// Submit request and fulfill it
608643
Vm.Wallet memory client = createClient(1);
@@ -885,3 +920,18 @@ contract ProofMarketTest is Test {
885920
assertEq(root, 0xe004c72e4cb697fa97669508df099edbc053309343772a25e56412fc7db8ebef);
886921
}
887922
}
923+
924+
contract TransientPriceLibTest is Test {
925+
using TransientPriceLib for TransientPrice;
926+
927+
/// forge-config: default.fuzz.runs = 10000
928+
function testFuzz_PackUnpack(bool valid, uint96 price) public {
929+
TransientPrice memory original = TransientPrice({valid: valid, price: price});
930+
931+
uint256 packed = TransientPriceLib.pack(original);
932+
TransientPrice memory unpacked = TransientPriceLib.unpack(packed);
933+
934+
assertEq(unpacked.valid, original.valid, "Valid flag mismatch");
935+
assertEq(unpacked.price, original.price, "Price mismatch");
936+
}
937+
}

docs/src/market/rfc.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,10 @@ struct AssessorJournal {
141141
// Root of the Merkle tree committing to the set of proven claims.
142142
// In the case of a batch of size one, this may simply be a claim digest.
143143
bytes32 root;
144-
// EIP712 domain separator
144+
// EIP712 domain separator.
145145
bytes32 eip712DomainSeparator;
146+
// The address of the prover that produced the assessor receipt.
147+
address prover;
146148
}
147149
```
148150

@@ -252,6 +254,25 @@ interface IProofMarket {
252254
/// @notice Delivers a batch of proofs. See IProofMarket.deliver for more information.
253255
function deliverBatch(Fulfillment[] calldata fills, bytes calldata assessorSeal, address prover) external;
254256
257+
/// @notice Checks the validity of the request and then writes the current auction price to
258+
/// transient storage.
259+
/// @dev When called within the same transaction, this method can be used to fulfill a request
260+
/// that is not locked. This is useful when the prover wishes to fulfill a request, but does
261+
/// not want to issue a lock transaction e.g. because the stake is to high or to save money by
262+
/// avoiding the gas costs of the lock transaction.
263+
function priceRequest(ProvingRequest calldata request, bytes calldata clientSignature) external;
264+
265+
/// @notice A combined call to `IProofMarket.priceRequest` and `IProofMarket.fulfillBatch`.
266+
/// The caller should provide the signed request and signature for each unlocked request they
267+
/// want to fulfill. Payment for unlocked requests will go to the provided `prover` address.
268+
function priceAndFulfillBatch(
269+
ProvingRequest[] calldata requests,
270+
bytes[] calldata clientSignatures,
271+
Fulfillment[] calldata fills,
272+
bytes calldata assessorSeal,
273+
address prover
274+
) external;
275+
255276
/// @notice Combined function to submit a new merkle root to the set-verifier and call fulfillBatch.
256277
/// @dev Useful to reduce the transaction count for fulfillments
257278
function submitRootAndFulfillBatch(

0 commit comments

Comments
 (0)