Skip to content

Commit 860d124

Browse files
MoonBoi9001claude
andcommitted
feat(dips): admit non-manager proposal senders backed by escrow
Senders that hold no on-chain agreement-manager role were rejected outright. This admits one when its recovered signer resolves to escrow at or above a configurable minimum (default 1 GRT); an empty snapshot is transient so a funded sender isn't branded unfunded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5c10cc9 commit 860d124

6 files changed

Lines changed: 282 additions & 25 deletions

File tree

crates/config/src/config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,10 @@ pub struct DipsConfig {
694694
/// Max seconds the role subgraph head may lag wall-clock before its data is
695695
/// treated as unreliable. 0 disables the check.
696696
pub role_subgraph_max_lag_secs: u64,
697+
/// Minimum escrow balance an untrusted sender's recovered signer must hold to
698+
/// pass the escrow fallback. TODO: derive from the configured price minimum
699+
/// plus a buffer for unknown entity counts instead of using a flat floor.
700+
pub min_escrow_grt: GRT,
697701
}
698702

699703
impl Default for DipsConfig {
@@ -709,6 +713,7 @@ impl Default for DipsConfig {
709713
role_refresh_interval_secs: 86_400,
710714
role_failopen_grace_secs: 3_600,
711715
role_subgraph_max_lag_secs: 1_800,
716+
min_escrow_grt: GRT::ONE,
712717
}
713718
}
714719
}

crates/config/src/grt.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ pub struct GRT(u128);
1212

1313
impl GRT {
1414
pub const ZERO: GRT = GRT(0);
15+
/// One GRT in wei (10^18).
16+
pub const ONE: GRT = GRT(1_000_000_000_000_000_000);
1517

1618
/// Convert GRT string to wei for test construction.
1719
/// Panics on invalid input - only use in tests.

crates/dips/src/escrow.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Escrow fallback for agreement-proposal senders that are not on-chain agreement
5+
//! managers: the role gate would reject them, but this lets one through when its
6+
//! recovered signer resolves to a payer with enough escrow (a limited permissionless path).
7+
8+
use thegraph_core::alloy::primitives::{Address, U256};
9+
10+
/// Outcome of looking a signer up in the escrow snapshot.
11+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12+
pub enum EscrowLookup {
13+
/// The snapshot has no signers at all (cold start or total outage), so the
14+
/// answer is "can't tell" rather than "unfunded".
15+
Unavailable,
16+
/// The signer resolved to a funded payer holding this balance (wei).
17+
Balance(U256),
18+
/// The snapshot is populated but the signer is not a funded payer.
19+
NotFound,
20+
}
21+
22+
/// Read-side view of the escrow snapshot, keyed on the cryptographically
23+
/// recovered signer (never a caller-written proposal field).
24+
pub trait EscrowSource: std::fmt::Debug + Send + Sync {
25+
fn lookup(&self, signer: Address) -> EscrowLookup;
26+
}
27+
28+
/// A fixed escrow snapshot. Used in tests and as a simple in-memory source;
29+
/// production uses the shared TAP escrow watcher.
30+
#[derive(Debug, Clone, Default)]
31+
pub struct StaticEscrow(pub std::collections::HashMap<Address, U256>);
32+
33+
impl EscrowSource for StaticEscrow {
34+
fn lookup(&self, signer: Address) -> EscrowLookup {
35+
if self.0.is_empty() {
36+
return EscrowLookup::Unavailable;
37+
}
38+
match self.0.get(&signer) {
39+
Some(balance) => EscrowLookup::Balance(*balance),
40+
None => EscrowLookup::NotFound,
41+
}
42+
}
43+
}
44+
45+
#[cfg(feature = "db")]
46+
impl EscrowSource for indexer_monitor::EscrowAccountsWatcher {
47+
fn lookup(&self, signer: Address) -> EscrowLookup {
48+
let snapshot = self.borrow();
49+
if snapshot.signer_count() == 0 {
50+
return EscrowLookup::Unavailable;
51+
}
52+
match snapshot.get_balance_for_signer(&signer) {
53+
Ok(balance) => EscrowLookup::Balance(balance),
54+
Err(_) => EscrowLookup::NotFound,
55+
}
56+
}
57+
}
58+
59+
#[cfg(test)]
60+
mod test {
61+
use thegraph_core::alloy::primitives::{address, U256};
62+
63+
use super::*;
64+
65+
const SIGNER: Address = address!("f39fd6e51aad88f6f4ce6ab8827279cfffb92266");
66+
67+
#[test]
68+
fn empty_snapshot_is_unavailable() {
69+
assert_eq!(
70+
StaticEscrow::default().lookup(SIGNER),
71+
EscrowLookup::Unavailable
72+
);
73+
}
74+
75+
#[test]
76+
fn known_signer_returns_balance() {
77+
let escrow = StaticEscrow([(SIGNER, U256::from(5))].into_iter().collect());
78+
assert_eq!(escrow.lookup(SIGNER), EscrowLookup::Balance(U256::from(5)));
79+
}
80+
81+
#[test]
82+
fn populated_snapshot_without_signer_is_not_found() {
83+
let other = Address::repeat_byte(0x11);
84+
let escrow = StaticEscrow([(other, U256::from(5))].into_iter().collect());
85+
assert_eq!(escrow.lookup(SIGNER), EscrowLookup::NotFound);
86+
}
87+
}

crates/dips/src/lib.rs

Lines changed: 142 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
5151
use std::sync::Arc;
5252

53+
use escrow::{EscrowLookup, EscrowSource};
5354
use server::DipsServerContext;
5455
use thegraph_core::alloy::{
5556
core::primitives::Address,
@@ -61,6 +62,7 @@ use thegraph_core::alloy::{
6162

6263
#[cfg(feature = "db")]
6364
pub mod database;
65+
pub mod escrow;
6466
pub mod inflight;
6567
pub mod ipfs;
6668
pub mod price;
@@ -200,6 +202,10 @@ pub enum DipsError {
200202
SenderNotTrusted { signer: Address },
201203
#[error("could not verify sender authorisation: {0}")]
202204
TrustVerificationUnavailable(String),
205+
#[error("sender {signer} has insufficient escrow to send agreement proposals")]
206+
InsufficientEscrow { signer: Address },
207+
#[error("could not verify sender escrow: {0}")]
208+
EscrowVerificationUnavailable(String),
203209
}
204210

205211
#[cfg(feature = "rpc")]
@@ -285,6 +291,25 @@ pub(crate) fn try_extract_deployment_id(rca_bytes: &[u8]) -> Option<String> {
285291
Some(bytes32_to_ipfs_hash(&metadata.subgraphDeploymentId.0))
286292
}
287293

294+
/// Authorize an untrusted sender by escrow: a resolved balance at or above
295+
/// `min_wei` passes; below or absent is InsufficientEscrow; a wholesale-empty
296+
/// snapshot is transient, so a funded sender isn't branded unfunded during an outage.
297+
fn verify_escrow(
298+
escrow: &dyn EscrowSource,
299+
signer: Address,
300+
min_wei: U256,
301+
) -> Result<(), DipsError> {
302+
match escrow.lookup(signer) {
303+
EscrowLookup::Balance(balance) if balance >= min_wei => Ok(()),
304+
EscrowLookup::Balance(_) | EscrowLookup::NotFound => {
305+
Err(DipsError::InsufficientEscrow { signer })
306+
}
307+
EscrowLookup::Unavailable => Err(DipsError::EscrowVerificationUnavailable(
308+
"escrow snapshot is empty".to_string(),
309+
)),
310+
}
311+
}
312+
288313
/// Validate and create a RecurringCollectionAgreement.
289314
///
290315
/// Performs validation:
@@ -310,17 +335,27 @@ pub async fn validate_and_create_rca(
310335
additional_networks,
311336
rca_domain,
312337
trusted_signers,
338+
escrow_source,
339+
min_escrow_wei,
313340
} = ctx.as_ref();
314341

315342
// Decode SignedRCA
316343
let signed_rca = SignedRecurringCollectionAgreement::abi_decode(rca_bytes.as_ref())
317344
.map_err(|e| DipsError::AbiDecoding(e.to_string()))?;
318345

319346
// Authenticate then authorize the sender: recover the EIP-712 signer, then
320-
// require it to hold the on-chain agreement-manager role before doing any work.
347+
// require it to be an on-chain agreement manager, or else a payer with escrow.
321348
let signer = signed_rca.recover_signer(rca_domain)?;
322349
tracing::debug!(%signer, "recovered RCA signer");
323-
trusted_signers.verify_trusted(signer).await?;
350+
match trusted_signers.verify_trusted(signer).await {
351+
Ok(()) => {}
352+
// Not a manager: fall back to an escrow check on the recovered signer
353+
// rather than rejecting; a transient role error stays transient.
354+
Err(DipsError::SenderNotTrusted { .. }) => {
355+
verify_escrow(escrow_source.as_ref(), signer, *min_escrow_wei)?;
356+
}
357+
Err(transient) => return Err(transient),
358+
}
324359

325360
// Validate service provider
326361
signed_rca.validate(expected_service_provider)?;
@@ -466,6 +501,7 @@ mod test {
466501

467502
use crate::{
468503
derive_agreement_id,
504+
escrow::StaticEscrow,
469505
ipfs::{FailingIpfsFetcher, MockIpfsFetcher},
470506
price::PriceCalculator,
471507
rca_eip712_domain,
@@ -513,21 +549,16 @@ mod test {
513549
additional_networks: Arc::new(BTreeMap::new()),
514550
rca_domain: test_rca_domain(),
515551
trusted_signers: trusted_signers_for_test(),
552+
escrow_source: Arc::new(StaticEscrow::default()),
553+
min_escrow_wei: U256::ZERO,
516554
})
517555
}
518556

519-
#[tokio::test]
520-
async fn test_validate_and_create_rca_untrusted_signer_rejected() {
521-
let service_provider = Address::repeat_byte(0x11);
522-
let rca = create_test_rca(
523-
Address::repeat_byte(0x42),
524-
service_provider,
525-
U256::from(200),
526-
U256::from(100),
527-
);
528-
529-
// Trusted set excludes the test signer, so a valid signature is rejected.
530-
let ctx = Arc::new(DipsServerContext {
557+
/// A context whose trusted set is empty (so the recovered signer is never a
558+
/// role holder), with a fixed escrow snapshot and minimum, to exercise C's
559+
/// escrow fallback path.
560+
fn untrusted_escrow_ctx(escrow: StaticEscrow, min_escrow_wei: U256) -> Arc<DipsServerContext> {
561+
Arc::new(DipsServerContext {
531562
rca_store: Arc::new(InMemoryRcaStore::default()),
532563
ipfs_fetcher: Arc::new(MockIpfsFetcher::default()),
533564
price_calculator: Arc::new(PriceCalculator::new(
@@ -539,12 +570,97 @@ mod test {
539570
additional_networks: Arc::new(BTreeMap::new()),
540571
rca_domain: test_rca_domain(),
541572
trusted_signers: Arc::new(StaticTrustedSigners::default()),
542-
});
543-
let rca_bytes = rca_to_wire_bytes(rca);
573+
escrow_source: Arc::new(escrow),
574+
min_escrow_wei,
575+
})
576+
}
544577

545-
let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await;
578+
#[tokio::test]
579+
async fn untrusted_sender_with_sufficient_escrow_passes() {
580+
let service_provider = Address::repeat_byte(0x11);
581+
let rca = create_test_rca(
582+
Address::repeat_byte(0x42),
583+
service_provider,
584+
U256::from(200),
585+
U256::from(100),
586+
);
587+
// The recovered signer (test_signer) holds 2 wei; the minimum is 1.
588+
let escrow = StaticEscrow(
589+
[(test_signer().address(), U256::from(2))]
590+
.into_iter()
591+
.collect(),
592+
);
593+
let ctx = untrusted_escrow_ctx(escrow, U256::from(1));
594+
595+
let result =
596+
super::validate_and_create_rca(ctx, &service_provider, rca_to_wire_bytes(rca)).await;
597+
598+
assert!(result.is_ok(), "got: {:?}", result);
599+
}
600+
601+
#[tokio::test]
602+
async fn untrusted_sender_below_minimum_is_insufficient() {
603+
let service_provider = Address::repeat_byte(0x11);
604+
let rca = create_test_rca(
605+
Address::repeat_byte(0x42),
606+
service_provider,
607+
U256::from(200),
608+
U256::from(100),
609+
);
610+
let escrow = StaticEscrow(
611+
[(test_signer().address(), U256::from(1))]
612+
.into_iter()
613+
.collect(),
614+
);
615+
let ctx = untrusted_escrow_ctx(escrow, U256::from(5));
616+
617+
let result =
618+
super::validate_and_create_rca(ctx, &service_provider, rca_to_wire_bytes(rca)).await;
619+
620+
assert!(matches!(result, Err(DipsError::InsufficientEscrow { .. })));
621+
}
546622

547-
assert!(matches!(result, Err(DipsError::SenderNotTrusted { .. })));
623+
#[tokio::test]
624+
async fn untrusted_sender_absent_from_escrow_is_insufficient() {
625+
let service_provider = Address::repeat_byte(0x11);
626+
let rca = create_test_rca(
627+
Address::repeat_byte(0x42),
628+
service_provider,
629+
U256::from(200),
630+
U256::from(100),
631+
);
632+
// Snapshot has a different address, so it is populated but not the signer.
633+
let escrow = StaticEscrow(
634+
[(Address::repeat_byte(0xEE), U256::from(99))]
635+
.into_iter()
636+
.collect(),
637+
);
638+
let ctx = untrusted_escrow_ctx(escrow, U256::from(1));
639+
640+
let result =
641+
super::validate_and_create_rca(ctx, &service_provider, rca_to_wire_bytes(rca)).await;
642+
643+
assert!(matches!(result, Err(DipsError::InsufficientEscrow { .. })));
644+
}
645+
646+
#[tokio::test]
647+
async fn untrusted_sender_with_empty_snapshot_is_transient() {
648+
let service_provider = Address::repeat_byte(0x11);
649+
let rca = create_test_rca(
650+
Address::repeat_byte(0x42),
651+
service_provider,
652+
U256::from(200),
653+
U256::from(100),
654+
);
655+
let ctx = untrusted_escrow_ctx(StaticEscrow::default(), U256::from(1));
656+
657+
let result =
658+
super::validate_and_create_rca(ctx, &service_provider, rca_to_wire_bytes(rca)).await;
659+
660+
assert!(matches!(
661+
result,
662+
Err(DipsError::EscrowVerificationUnavailable(_))
663+
));
548664
}
549665

550666
/// Sign an RCA with the fixed test key over the test domain and ABI-encode
@@ -891,6 +1007,8 @@ mod test {
8911007
additional_networks: Arc::new(BTreeMap::new()),
8921008
rca_domain: test_rca_domain(),
8931009
trusted_signers: trusted_signers_for_test(),
1010+
escrow_source: Arc::new(StaticEscrow::default()),
1011+
min_escrow_wei: U256::ZERO,
8941012
});
8951013

8961014
let rca_bytes = rca_to_wire_bytes(rca);
@@ -1066,6 +1184,8 @@ mod test {
10661184
additional_networks: Arc::new(BTreeMap::new()),
10671185
rca_domain: test_rca_domain(),
10681186
trusted_signers: trusted_signers_for_test(),
1187+
escrow_source: Arc::new(StaticEscrow::default()),
1188+
min_escrow_wei: U256::ZERO,
10691189
});
10701190

10711191
let rca_bytes = rca_to_wire_bytes(rca);
@@ -1102,6 +1222,8 @@ mod test {
11021222
additional_networks: Arc::new(BTreeMap::new()),
11031223
rca_domain: test_rca_domain(),
11041224
trusted_signers: trusted_signers_for_test(),
1225+
escrow_source: Arc::new(StaticEscrow::default()),
1226+
min_escrow_wei: U256::ZERO,
11051227
});
11061228

11071229
let rca_bytes = rca_to_wire_bytes(rca);
@@ -1138,6 +1260,8 @@ mod test {
11381260
additional_networks: Arc::new(BTreeMap::new()),
11391261
rca_domain: test_rca_domain(),
11401262
trusted_signers: trusted_signers_for_test(),
1263+
escrow_source: Arc::new(StaticEscrow::default()),
1264+
min_escrow_wei: U256::ZERO,
11411265
});
11421266

11431267
let rca_bytes = rca_to_wire_bytes(rca);

0 commit comments

Comments
 (0)