Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions crates/settlement/examples/e2e_anvil.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

use alloy::providers::{Provider, ProviderBuilder};
use alloy::sol;
use alloy_primitives::{Address, B256, U256};
use alloy_primitives::{keccak256, Address, B256, U256};
use surplus_settlement::chain::SettlementClient;
use surplus_settlement::{
domain, instrument_hash, Batch, Order, SignedFill, Signer, SIDE_BUY, SIDE_SELL,
Expand Down Expand Up @@ -223,11 +223,12 @@ async fn main() -> anyhow::Result<()> {
.map(|a| a.sign_digest(digest).to_vec())
.collect(),
);
operator
.settle_batch_attested(book_id, &batch, sigs[..2].to_vec())
let (_btx, batch_lots) = operator
.settle_batch_attested_with_lots(book_id, &batch, sigs[..2].to_vec())
.await?;
let att_lot = batch_lots[0];
println!(
"attested batch settled (2-of-3 quorum), book nonce -> {}",
"attested batch settled (2-of-3 quorum), book nonce -> {}, lot {att_lot:#x}",
nonce + 1
);

Expand Down Expand Up @@ -273,6 +274,48 @@ async fn main() -> anyhow::Result<()> {
.await?;
println!("proven batch settled against strict verifier (match-in-circuit publicValues bound)");

println!("\nE2E PASS: atomic fill, receipt redemption, collateral default, attested + proven batches");
// ── 6. Attested redemption: the holder consumes a batch-minted lot but won't
// sign the receipt. The issuing book's quorum vouches service; after the
// challenge window the operator finalizes and is paid — NO unjust default
// or slash (this is the anti-grief capability the operator pump drives). ─
let red3 = buyer_client.request_redemption(att_lot, 40_000).await?;
let liab_before = operator.liability_of(seller.address()).await?;
let defaults_before = operator.defaults_count().await?;
let work3 = surplus_settlement::work_commitment(
keccak256(b"anthropic/claude-opus-4-8:output"),
keccak256(br#"[{"role":"user","content":"hi"}]"#),
keccak256(b"attested output"),
);
// The book quorum signs the receipt digest — the holder never does.
let rdigest = surplus_settlement::receipt_digest(red3, 40_000, work3, &dom);
let rsigs = surplus_settlement::sort_quorum_sigs(
rdigest,
attesters
.iter()
.map(|a| a.sign_digest(rdigest).to_vec())
.collect(),
);
operator
.settle_redemption_attested(book_id, red3, 40_000, work3, rsigs[..2].to_vec())
.await?;
// Advance past the challenge window (deploy default 1h), then finalize.
raw.raw_request::<_, serde_json::Value>("evm_increaseTime".into(), (3600 + 60,))
.await?;
raw.raw_request::<_, serde_json::Value>("evm_mine".into(), ())
.await?;
operator.finalize_attested(red3).await?;
let liab_after = operator.liability_of(seller.address()).await?;
assert!(
liab_after < liab_before,
"attested redemption must free the served liability"
);
assert_eq!(
operator.defaults_count().await?,
defaults_before,
"no default: the operator was paid via attestation, not defaulted+slashed"
);
println!("attested redemption: quorum vouched, window passed, finalized — liability freed, no default");

println!("\nE2E PASS: fill, receipt redemption, default, attested + proven batches, attested redemption");
Ok(())
}
75 changes: 75 additions & 0 deletions crates/settlement/src/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ sol! {
function settleRedemption(bytes32 redemptionId, uint64 servedTokens, bytes32 workCommitment, bytes calldata holderSig) external;
function settleRedemptionAttested(bytes32 bookId, bytes32 redemptionId, uint64 servedTokens, bytes32 workCommitment, bytes[] calldata sigs) external;
function finalizeAttested(bytes32 redemptionId) external;
function lotBook(bytes32 lotId) external view returns (bytes32);
function challengeAttested(bytes32 redemptionId) external;
struct SpendPermit {
bytes32 lotId;
Expand Down Expand Up @@ -353,6 +354,49 @@ impl SettlementClient {
Ok(())
}

/// The book a lot was minted into (NO_BOOK for trustless `settleFills` lots,
/// which cannot be attested). Needed to drive `settleRedemptionAttested`.
pub async fn lot_book(&self, lot_id: B256) -> anyhow::Result<B256> {
Ok(self.contract.lotBook(lot_id).call().await?)
}

/// Attest service for a redemption the holder won't receipt: the issuing
/// book's quorum signs `receiptDigest(rid, served, work)`, posting a
/// challengeable claim. Saves the operator from an unjust default+slash.
pub async fn settle_redemption_attested(
&self,
book_id: B256,
redemption_id: B256,
served: u64,
work_commitment: B256,
sigs: Vec<Vec<u8>>,
) -> anyhow::Result<B256> {
let sigs: Vec<alloy_primitives::Bytes> = sigs.into_iter().map(Into::into).collect();
let r = self
.contract
.settleRedemptionAttested(book_id, redemption_id, served, work_commitment, sigs)
.send()
.await?
.get_receipt()
.await?;
anyhow::ensure!(r.status(), "settleRedemptionAttested reverted");
Ok(r.transaction_hash)
}

/// Finalize an unchallenged attestation once its window has passed (anyone
/// may call; the settlement is exactly what the quorum vouched).
pub async fn finalize_attested(&self, redemption_id: B256) -> anyhow::Result<B256> {
let r = self
.contract
.finalizeAttested(redemption_id)
.send()
.await?
.get_receipt()
.await?;
anyhow::ensure!(r.status(), "finalizeAttested reverted");
Ok(r.transaction_hash)
}

pub async fn claim_default(&self, redemption_id: B256) -> anyhow::Result<U256> {
let receipt = self
.contract
Expand Down Expand Up @@ -482,6 +526,37 @@ impl SettlementClient {
Ok(receipt.transaction_hash)
}

/// Attested submit returning the minted lot ids (FillSettled events), so a
/// caller can drive an attested redemption of a batch-minted lot.
pub async fn settle_batch_attested_with_lots(
&self,
book_id: B256,
batch: &Batch,
sigs: Vec<Vec<u8>>,
) -> anyhow::Result<(B256, Vec<B256>)> {
let fills: Vec<_> = batch.batch_fills().iter().map(to_batch_fill).collect();
let sigs: Vec<alloy_primitives::Bytes> = sigs.into_iter().map(Into::into).collect();
let receipt = self
.contract
.settleBatchAttested(book_id, fills, sigs)
.send()
.await?
.get_receipt()
.await?;
anyhow::ensure!(receipt.status(), "settleBatchAttested reverted");
let lots = receipt
.logs()
.iter()
.filter_map(|log| {
log.log_decode::<ISurplusSettlement::FillSettled>()
.ok()
.map(|l| l.inner.lotId)
})
.filter(|l| *l != B256::ZERO)
.collect();
Ok((receipt.transaction_hash, lots))
}

pub async fn settle_batch_proven(
&self,
book_id: B256,
Expand Down
3 changes: 3 additions & 0 deletions operator/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ pub fn rate_limited(app: Router) -> Router {
/// Fills clear without any external poke; failures requeue and retry on the
/// next tick. Spawned by both the lite and the blueprint bins.
pub fn spawn_auto_flush(venue: Shared) {
// Anti-grief: vouch service for served-but-unreceipted redemptions through the
// quorum and finalize them, so a non-signing holder can't force a default.
crate::redeem::spawn_redeem_attest(venue.clone());
let interval = std::env::var("SURPLUS_FLUSH_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
Expand Down
Loading
Loading