diff --git a/contracts/attestation/src/dynamic_fees.rs b/contracts/attestation/src/dynamic_fees.rs index a4605c06..91be0069 100644 --- a/contracts/attestation/src/dynamic_fees.rs +++ b/contracts/attestation/src/dynamic_fees.rs @@ -165,6 +165,14 @@ pub enum DataKey { ArchivedAttestation(Address, soroban_sdk::String), /// Lightweight archive pointer. ArchivePointer(Address, soroban_sdk::String), + + // ── Delegated relayer allowlist ─────────────────────────────── + /// Whether `Address` is an allowlisted relayer. + /// + /// Value is `bool` (`true` = allowlisted). Removing the key (rather + /// than setting it to `false`) is the canonical way to de-list a + /// relayer, so `is_allowlisted` checks `has` rather than `get`. + RelayerAllowlist(Address), } // ════════════════════════════════════════════════════════════════════ diff --git a/contracts/attestation/src/events.rs b/contracts/attestation/src/events.rs index 8459626f..4fd8d10e 100644 --- a/contracts/attestation/src/events.rs +++ b/contracts/attestation/src/events.rs @@ -164,6 +164,10 @@ pub const TOPIC_EPOCH_CHECKPOINT: Symbol = symbol_short!("ep_ckpt"); pub const TOPIC_EPOCH_ADVANCED: Symbol = symbol_short!("ep_adv"); /// Topic: backfill checkpoint emitted every N submissions (global counter) pub const TOPIC_BACKFILL_CHECKPOINT: Symbol = symbol_short!("bkf_chk"); +/// Topic: relayer added to delegated-submission allowlist +pub const TOPIC_RELAYER_ADDED: Symbol = symbol_short!("rl_add"); +/// Topic: relayer removed from delegated-submission allowlist +pub const TOPIC_RELAYER_REMOVED: Symbol = symbol_short!("rl_rem"); // ════════════════════════════════════════════════════════════════════ // Normalized Event Data Structures @@ -1980,4 +1984,82 @@ pub fn emit_backfill_checkpoint( state_commitment: state_commitment.clone(), }; env.events().publish((TOPIC_BACKFILL_CHECKPOINT,), event); -} \ No newline at end of file +} + +// ════════════════════════════════════════════════════════════════════ +// Delegated Relayer Allowlist Events +// ════════════════════════════════════════════════════════════════════ + +/// Normalized payload for `RelayerAdded` events. +/// +/// Emitted when the admin adds an address to the delegated-submission +/// relayer allowlist. +/// +/// | Event Catalog | Topic | Secondary topic | +/// |---|---|--| +/// | RelayerAdded | `rl_add` | `relayer` | +#[contracttype] +#[derive(Clone, Debug)] +pub struct RelayerAddedEvent { + /// Address added to the allowlist. + pub relayer: Address, + /// Admin address that performed the operation. + pub added_by: Address, +} + +/// Normalized payload for `RelayerRemoved` events. +/// +/// Emitted when the admin removes an address from the delegated-submission +/// relayer allowlist. +/// +/// | Event Catalog | Topic | Secondary topic | +/// |---|---|--| +/// | RelayerRemoved | `rl_rem` | `relayer` | +#[contracttype] +#[derive(Clone, Debug)] +pub struct RelayerRemovedEvent { + /// Address removed from the allowlist. + pub relayer: Address, + /// Admin address that performed the operation. + pub removed_by: Address, +} + +/// Emit a `RelayerAdded` event. +/// +/// # Arguments +/// +/// * `env` – Soroban execution environment. +/// * `relayer` – Address added to the allowlist. +/// * `added_by` – Admin address that authorised the addition. +/// +/// # Events +/// +/// Publishes `(rl_add, relayer)` → `RelayerAddedEvent`. +pub fn emit_relayer_added(env: &Env, relayer: &Address, added_by: &Address) { + let event = RelayerAddedEvent { + relayer: relayer.clone(), + added_by: added_by.clone(), + }; + env.events() + .publish((TOPIC_RELAYER_ADDED, relayer.clone()), event); +} + +/// Emit a `RelayerRemoved` event. +/// +/// # Arguments +/// +/// * `env` – Soroban execution environment. +/// * `relayer` – Address removed from the allowlist. +/// * `removed_by` – Admin address that authorised the removal. +/// +/// # Events +/// +/// Publishes `(rl_rem, relayer)` → `RelayerRemovedEvent`. +pub fn emit_relayer_removed(env: &Env, relayer: &Address, removed_by: &Address) { + let event = RelayerRemovedEvent { + relayer: relayer.clone(), + removed_by: removed_by.clone(), + }; + env.events() + .publish((TOPIC_RELAYER_REMOVED, relayer.clone()), event); +} diff --git a/contracts/attestation/src/lib.rs b/contracts/attestation/src/lib.rs index 991aca1a..1b115783 100644 --- a/contracts/attestation/src/lib.rs +++ b/contracts/attestation/src/lib.rs @@ -16,6 +16,12 @@ use veritasor_common::replay_protection; // Nonce channels pub const NONCE_CHANNEL_ADMIN: u32 = 0; pub const NONCE_CHANNEL_BUSINESS: u32 = 1; +/// Nonce channel for delegated permit signatures. +/// +/// Per-business nonce stream consumed by `submit_attestation_delegated`. +/// Isolating delegated permits from normal business submissions prevents +/// a permit nonce from being replayed as a regular submission and vice versa. +pub const NONCE_CHANNEL_DELEGATED: u32 = 2; // Key Tags const ANOMALY_KEY_TAG: (u32,) = (3,); @@ -64,7 +70,7 @@ pub use dynamic_fees::{RevokeProposal, DEFAULT_REVOKE_GRACE_SECONDS}; pub use events::{ AttestationCleanedUpEvent, AttestationMigratedEvent, AttestationRevokedEvent, AttestationSubmittedEvent, ProofHashUpdatedEvent, - RelayerGasReportedEvent, + RelayerAddedEvent, RelayerGasReportedEvent, RelayerRemovedEvent, RevocationCancelledEvent, RevocationCommittedEvent, RevocationProposedEvent, }; pub use fees::{collect_flat_fee, CollectorRotationProposal, FlatFeeConfig}; @@ -152,6 +158,56 @@ pub trait AttestorStakingContractTrait { fn slash(env: Env, attestor: Address, amount: i128, dispute_id: u64); } +/// Off-chain permit authorising a specific relayer to submit one attestation +/// on behalf of a business. +/// +/// The business signs the canonical XDR serialisation of this struct via +/// [`Signature`]. `submit_attestation_delegated` verifies the signature, +/// checks the caller is allowlisted, enforces the permit expiry window, and +/// consumes the nonce before delegating to [`AttestationContract::execute_submission`]. +/// +/// ## Security properties +/// +/// | Property | Mechanism | +/// |---|---| +/// | Forgery resistance | Soroban `env.verify(&sig)` ties the signature to the permit hash | +/// | Permit binding | `relayer` field limits which address can consume the permit | +/// | Replay prevention | `nonce` on `NONCE_CHANNEL_DELEGATED` is monotonically consumed | +/// | Expiry | `permit_expiry` rejects stale permits even if nonce is still valid | +/// | Griefing resistance | Caller must be in the admin-maintained relayer allowlist | +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DelegatedPermit { + /// Business address that signs and authorises this permit. + pub business: Address, + /// Relayer address that is permitted to submit this permit. + /// + /// `submit_attestation_delegated` rejects the call if `caller != relayer`. + pub relayer: Address, + /// Attestation period (e.g. `"202501"`). + pub period: String, + /// Merkle root of the revenue dataset. + pub merkle_root: BytesN<32>, + /// Off-chain event timestamp supplied by the business. + pub timestamp: u64, + /// Schema version of the Merkle tree. + pub version: u32, + /// Optional off-chain proof hash linking to the full dataset. + pub proof_hash: Option>, + /// Optional on-chain expiry timestamp for the attestation record itself. + pub expiry_timestamp: Option, + /// Monotonic nonce on `NONCE_CHANNEL_DELEGATED` for the `business` address. + /// + /// Must exactly match the stored nonce at submission time. Consumed (incremented) + /// on success to prevent permit replay. + pub nonce: u64, + /// Wall-clock deadline (ledger timestamp) after which the permit is invalid. + /// + /// Prevents indefinite replay of signed-but-unsubmitted permits. + /// Set by the business to a reasonable future window (e.g. 10–60 minutes). + pub permit_expiry: u64, +} + #[contract] pub struct AttestationContract; @@ -162,6 +218,7 @@ mod audit_log_integration_test; mod active_submission_test; #[cfg(test)] +mod delegated_relayer_test; mod backfill_checkpoint_test; /// Vote-weight snapshot tests (issue #512). Always compiled into the test @@ -520,7 +577,158 @@ impl AttestationContract { replay_protection::get_nonce(&env, &actor, channel) } - pub fn submit_attestation( + // ──────────────────────────────────────────────────────────────── + // Relayer allowlist management + // ──────────────────────────────────────────────────────────────── + + /// Admin: add `relayer` to the delegated-submission allowlist. + /// + /// Only allowlisted relayers may call `submit_attestation_delegated`. + /// Adding an address that is already allowlisted is idempotent. + /// + /// # Panics + /// - Caller does not hold ADMIN role. + pub fn add_relayer(env: Env, caller: Address, relayer: Address) { + access_control::require_admin(&env, &caller); + env.storage() + .instance() + .set(&DataKey::RelayerAllowlist(relayer.clone()), &true); + events::emit_relayer_added(&env, &relayer, &caller); + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_BUMP); + } + + /// Admin: remove `relayer` from the delegated-submission allowlist. + /// + /// Removing an address that was not allowlisted is idempotent. + /// + /// # Panics + /// - Caller does not hold ADMIN role. + pub fn remove_relayer(env: Env, caller: Address, relayer: Address) { + access_control::require_admin(&env, &caller); + env.storage() + .instance() + .remove(&DataKey::RelayerAllowlist(relayer.clone())); + events::emit_relayer_removed(&env, &relayer, &caller); + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_BUMP); + } + + /// Returns `true` if `relayer` is currently on the delegated-submission allowlist. + pub fn is_relayer(env: Env, relayer: Address) -> bool { + env.storage() + .instance() + .has(&DataKey::RelayerAllowlist(relayer)) + } + + // ──────────────────────────────────────────────────────────────── + // Delegated submission + // ──────────────────────────────────────────────────────────────── + + /// Submit an attestation on behalf of a business using a signed permit. + /// + /// This entry point enables gas abstraction: a business signs a + /// [`DelegatedPermit`] off-chain and hands it to an allowlisted relayer. + /// The relayer calls this function, paying the transaction fee, while the + /// attestation is recorded as if the business submitted it directly. + /// + /// ## Execution flow + /// + /// 1. **Allowlist check** — `caller` must be in the admin-maintained + /// relayer allowlist (`add_relayer`/`remove_relayer`). + /// 2. **Permit–caller binding** — `permit.relayer` must equal `caller` so + /// that a permit cannot be consumed by a different relayer. + /// 3. **Signature verification** — `env.verify(&sig)` recovers the signer + /// address. It must equal `permit.business`. + /// 4. **Permit expiry** — `permit.permit_expiry > ledger timestamp`; stale + /// permits are rejected. + /// 5. **Nonce consumption** — `permit.nonce` is verified and incremented on + /// `NONCE_CHANNEL_DELEGATED` for `permit.business`, preventing replay. + /// 6. **Submission** — delegates to [`execute_submission`] with `caller` as + /// the fee payer. + /// + /// ## Parameters + /// + /// - `caller` — relayer address (must be allowlisted and match `permit.relayer`). + /// - `permit` — the [`DelegatedPermit`] signed by the business. + /// - `sig` — Soroban [`Signature`] produced by the business over the + /// XDR serialisation of `permit`. + /// + /// ## Panics + /// + /// | Condition | Message | + /// |---|---| + /// | Caller not in allowlist | `"caller is not an allowlisted relayer"` | + /// | Caller ≠ permit.relayer | `"caller does not match permit relayer"` | + /// | Signer ≠ permit.business | `"permit signature is not from the business"` | + /// | Permit expired | `"permit has expired"` | + /// | Nonce mismatch | `"nonce mismatch for actor/channel pair"` (from replay_protection) | + pub fn submit_attestation_delegated( + env: Env, + caller: Address, + permit: DelegatedPermit, + sig: Signature, + ) { + // 1. Allowlist check — reject hostile/unknown relayers up front. + assert!( + env.storage() + .instance() + .has(&DataKey::RelayerAllowlist(caller.clone())), + "caller is not an allowlisted relayer" + ); + + // 2. Permit–caller binding — prevents a different allowlisted relayer + // from front-running and consuming a permit intended for another. + assert!( + caller == permit.relayer, + "caller does not match permit relayer" + ); + + // 3. Signature verification — the business must have signed the permit. + // `env.verify` recovers the signer from the Soroban signature and + // checks the payload matches; it panics on invalid signatures. + let signer = env.verify(&sig); + assert!( + signer == permit.business, + "permit signature is not from the business" + ); + + // 4. Permit expiry — reject stale permits to limit the window for + // signed-but-unsubmitted permits being used maliciously. + assert!( + env.ledger().timestamp() < permit.permit_expiry, + "permit has expired" + ); + + // 5. Nonce consumption — must happen BEFORE execute_submission so that + // any panic inside execute_submission does not leave the nonce consumed + // without a matching on-chain attestation. Soroban's transactional + // semantics roll back all storage writes on panic, so ordering here is + // for clarity rather than strict necessity, but it is the safer idiom. + replay_protection::verify_and_increment_nonce( + &env, + &permit.business, + NONCE_CHANNEL_DELEGATED, + permit.nonce, + ); + + // 6. Submit the attestation — caller (relayer) is the fee payer; + // business is the attestation owner. + Self::execute_submission( + &env, + &caller, // payer = relayer + None, // no attestor + &permit.business, + &permit.period, + &permit.merkle_root, + permit.timestamp, + permit.version, + &permit.proof_hash, + permit.expiry_timestamp, + ); + } env: Env, business: Address, period: String,