Skip to content

Commit af71247

Browse files
authored
Merge pull request #350 from Bruno755/issue-289-291-292-290-escrow-security-updates
feat: add escrow contract enhancements and security documentation
2 parents 7b00aec + 7c23bbe commit af71247

3 files changed

Lines changed: 302 additions & 1 deletion

File tree

SECURITY.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,25 @@ const cfg: AgentConfig = {
107107
- **Mainnet spending cap:** `AGENT_SPENDING_LIMIT` is rejected at startup if it exceeds `10,000` on `mainnet`, preventing runaway agent spend.
108108
- **Exponential back-off:** All RPC calls use retry logic with jitter to reduce the attack surface of timing-based denial-of-service against the agent.
109109
- **Dependency pinning:** Keep `package.json` dependencies pinned to exact versions and audit regularly with `npm audit`.
110+
111+
---
112+
113+
## Known Limitations
114+
115+
Users should be aware of the following limitations when deploying Nodal AI:
116+
117+
### 1. In-Memory Nonce Store
118+
119+
The x402 nonce store is in-memory and not persisted to disk. If the agent process restarts, previously-seen nonces are cleared. This creates a window where replayed x402 challenges could be accepted until the agent is re-initialized with fresh state. See [#207](https://github.com/Nodal-stellar/Nodal-AI/issues/207) for persistent nonce store implementation.
120+
121+
### 2. Soroban Simulation Disabled for Payment Estimates
122+
123+
`StellarPaymentTool` does not use Soroban simulation to estimate transaction fees. Fee estimates are calculated as base-fee only, without accounting for transaction complexity or network congestion. Production deployments should verify fee estimates through a secondary mechanism or use a higher fee buffer.
124+
125+
### 3. AWS Secret Fetch Pattern
126+
127+
`config.ts` uses `execSync` to fetch `AGENT_SECRET_KEY` from AWS Secrets Manager. This pattern has inherent security risks including exposing command output in error logs and blocking the event loop during secret retrieval. See [#210](https://github.com/Nodal-stellar/Nodal-AI/issues/210) for a planned non-blocking alternative.
128+
129+
### 4. Webhook Delivery Retry Limits
130+
131+
Webhook delivery has no retry mechanism for non-2xx responses beyond the initial `withRetry` attempts configured in the deployment. If a webhook consumer is temporarily unavailable, events may be silently dropped without re-queueing or manual intervention capability.

contracts/escrow/src/lib.rs

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ pub enum DataKey {
3333
Amount,
3434
Expiry,
3535
Released,
36+
InitializedAt,
37+
PendingArbiter,
38+
PendingArbiterTime,
3639
}
3740

3841
// ─── Escrow State ─────────────────────────────────────────────────────────────
@@ -46,6 +49,7 @@ pub struct EscrowState {
4649
pub amount: i128,
4750
pub expiry: u64,
4851
pub released: bool,
52+
pub initialized_at: u64,
4953
}
5054

5155
// ─── Contract Errors ──────────────────────────────────────────────────────────
@@ -72,6 +76,10 @@ pub enum EscrowError {
7276
NotInitialized = 8,
7377
/// depositor, recipient, and arbiter must all be distinct addresses.
7478
InvalidParties = 9,
79+
/// The arbiter rotation time-lock has not yet expired.
80+
RotationLocked = 10,
81+
/// No pending arbiter rotation proposal.
82+
NoPendingRotation = 11,
7583
}
7684

7785
// ─── Contract ─────────────────────────────────────────────────────────────────
@@ -88,7 +96,11 @@ impl EscrowContract {
8896
/// * `depositor` - Party locking the funds.
8997
/// * `recipient` - Party who receives funds on release.
9098
/// * `arbiter` - Trusted party who authorises release.
91-
/// * `token` - SAC token contract address.
99+
/// * `token` - SAC token contract address. **Only Stellar Asset Contract (SAC) tokens
100+
/// conforming to the Stellar token interface are supported.** Non-SAC tokens
101+
/// with incompatible interfaces will cause a panic. Verify the token address
102+
/// is a SAC before calling initialize() by checking the token's contract code
103+
/// or attempting to read its decimals().
92104
/// * `amount` - Token amount (stroop-equivalent units).
93105
/// * `expiry` - Unix timestamp after which depositor may refund.
94106
///
@@ -148,6 +160,7 @@ impl EscrowContract {
148160
);
149161

150162
// Persist state
163+
let now = env.ledger().timestamp();
151164
env.storage()
152165
.instance()
153166
.set(&DataKey::Depositor, &depositor);
@@ -159,6 +172,9 @@ impl EscrowContract {
159172
env.storage().instance().set(&DataKey::Amount, &amount);
160173
env.storage().instance().set(&DataKey::Expiry, &expiry);
161174
env.storage().instance().set(&DataKey::Released, &false);
175+
env.storage()
176+
.instance()
177+
.set(&DataKey::InitializedAt, &now);
162178

163179
env.events().publish(
164180
(
@@ -343,6 +359,11 @@ impl EscrowContract {
343359
.instance()
344360
.get(&DataKey::Released)
345361
.unwrap_or(false),
362+
initialized_at: env
363+
.storage()
364+
.instance()
365+
.get(&DataKey::InitializedAt)
366+
.expect("escrow: state corrupted"),
346367
}
347368
}
348369

@@ -494,6 +515,99 @@ impl EscrowContract {
494515
);
495516
}
496517

518+
/// Propose a new arbiter. Only callable by the stored depositor.
519+
///
520+
/// Initiates a time-locked arbiter rotation to prevent instant hostile takeover.
521+
/// After the 24-hour time-lock, `accept_arbiter_rotation` must be called to finalize.
522+
///
523+
/// # Arguments
524+
/// * `env` - The execution environment.
525+
/// * `depositor` - Must match the depositor recorded at initialisation.
526+
/// * `new_arbiter` - The proposed new arbiter address.
527+
///
528+
/// # Panics
529+
/// * `NotDepositor` - If the caller is not the stored depositor.
530+
/// * `NotInitialized` - If the escrow has not been initialized.
531+
///
532+
/// # Return Value
533+
/// None.
534+
pub fn propose_new_arbiter(env: Env, depositor: Address, new_arbiter: Address) {
535+
let stored_depositor: Address = env
536+
.storage()
537+
.instance()
538+
.get(&DataKey::Depositor)
539+
.expect("escrow: state corrupted");
540+
stored_depositor.require_auth();
541+
if depositor != stored_depositor {
542+
panic_with_error!(&env, EscrowError::NotDepositor);
543+
}
544+
545+
let now = env.ledger().timestamp();
546+
env.storage()
547+
.instance()
548+
.set(&DataKey::PendingArbiter, &new_arbiter);
549+
env.storage()
550+
.instance()
551+
.set(&DataKey::PendingArbiterTime, &now);
552+
553+
env.events().publish(
554+
(Symbol::new(&env, "arbiter_rotation_proposed"),),
555+
(depositor, new_arbiter, now),
556+
);
557+
}
558+
559+
/// Accept the pending arbiter rotation after the 24-hour time-lock.
560+
///
561+
/// Finalizes the arbiter change if 24 hours have passed since `propose_new_arbiter` was called.
562+
/// Can be called by anyone once the time-lock has expired.
563+
///
564+
/// # Arguments
565+
/// * `env` - The execution environment.
566+
///
567+
/// # Panics
568+
/// * `NoPendingRotation` - If no arbiter rotation has been proposed.
569+
/// * `RotationLocked` - If the 24-hour time-lock has not yet elapsed.
570+
///
571+
/// # Return Value
572+
/// None.
573+
pub fn accept_arbiter_rotation(env: Env) {
574+
const ROTATION_DELAY: u64 = 86_400; // 24 hours in seconds
575+
576+
if !env.storage().instance().has(&DataKey::PendingArbiter) {
577+
panic_with_error!(&env, EscrowError::NoPendingRotation);
578+
}
579+
580+
let pending_time: u64 = env
581+
.storage()
582+
.instance()
583+
.get(&DataKey::PendingArbiterTime)
584+
.expect("escrow: state corrupted");
585+
let now = env.ledger().timestamp();
586+
587+
if now < pending_time + ROTATION_DELAY {
588+
panic_with_error!(&env, EscrowError::RotationLocked);
589+
}
590+
591+
let new_arbiter: Address = env
592+
.storage()
593+
.instance()
594+
.get(&DataKey::PendingArbiter)
595+
.expect("escrow: state corrupted");
596+
597+
env.storage().instance().set(&DataKey::Arbiter, &new_arbiter);
598+
env.storage()
599+
.instance()
600+
.remove(&DataKey::PendingArbiter);
601+
env.storage()
602+
.instance()
603+
.remove(&DataKey::PendingArbiterTime);
604+
605+
env.events().publish(
606+
(Symbol::new(&env, "arbiter_rotation_accepted"),),
607+
(new_arbiter, now),
608+
);
609+
}
610+
497611
// ─── Internal helpers ────────────────────────────────────────────────────
498612

499613
fn assert_not_released(env: &Env) {

contracts/escrow/src/test.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,4 +862,169 @@ mod tests {
862862
assert_eq!(token.balance(&impostor), 0, "impostor must receive nothing");
863863
assert_eq!(token.balance(&contract_id), 0);
864864
}
865+
866+
// ── initialized_at field tests (issue #290) ──────────────────────────────
867+
868+
// 31. get_state returns initialized_at after initialize
869+
#[test]
870+
fn test_get_state_includes_initialized_at() {
871+
let env = Env::default();
872+
env.mock_all_auths();
873+
let depositor = Address::generate(&env);
874+
let recipient = Address::generate(&env);
875+
let arbiter = Address::generate(&env);
876+
let (token_id, _) = create_token(&env, &depositor);
877+
StellarAssetClient::new(&env, &token_id).mint(&depositor, &1_000);
878+
let contract_id = env.register_contract(None, EscrowContract);
879+
let client = EscrowContractClient::new(&env, &contract_id);
880+
let now = env.ledger().timestamp();
881+
let expiry = now + EXPIRY_OFFSET;
882+
client.initialize(&depositor, &recipient, &arbiter, &token_id, &500, &expiry);
883+
let state = client.get_state();
884+
assert_eq!(state.initialized_at, now, "initialized_at should match ledger timestamp");
885+
}
886+
887+
// 32. initialized_at reflects ledger timestamp at initialization time
888+
#[test]
889+
fn test_initialized_at_reflects_ledger_timestamp() {
890+
let env = Env::default();
891+
env.mock_all_auths();
892+
let depositor = Address::generate(&env);
893+
let recipient = Address::generate(&env);
894+
let arbiter = Address::generate(&env);
895+
let (token_id, _) = create_token(&env, &depositor);
896+
StellarAssetClient::new(&env, &token_id).mint(&depositor, &1_000);
897+
env.ledger().with_mut(|li| li.timestamp = 500);
898+
let contract_id = env.register_contract(None, EscrowContract);
899+
let client = EscrowContractClient::new(&env, &contract_id);
900+
let expiry = 500 + EXPIRY_OFFSET;
901+
client.initialize(&depositor, &recipient, &arbiter, &token_id, &500, &expiry);
902+
let state = client.get_state();
903+
assert_eq!(state.initialized_at, 500, "initialized_at should be 500");
904+
}
905+
906+
// ── Arbiter rotation tests (issue #291) ──────────────────────────────────
907+
908+
// 33. propose_new_arbiter requires depositor auth
909+
#[test]
910+
fn test_propose_arbiter_requires_depositor_auth() {
911+
let env = Env::default();
912+
env.mock_all_auths();
913+
let depositor = Address::generate(&env);
914+
let recipient = Address::generate(&env);
915+
let arbiter = Address::generate(&env);
916+
let new_arbiter = Address::generate(&env);
917+
let (token_id, _) = create_token(&env, &depositor);
918+
StellarAssetClient::new(&env, &token_id).mint(&depositor, &1_000);
919+
let contract_id = env.register_contract(None, EscrowContract);
920+
let client = EscrowContractClient::new(&env, &contract_id);
921+
let expiry = env.ledger().timestamp() + EXPIRY_OFFSET;
922+
client.initialize(&depositor, &recipient, &arbiter, &token_id, &500, &expiry);
923+
// Propose with correct depositor should succeed
924+
client.propose_new_arbiter(&depositor, &new_arbiter);
925+
}
926+
927+
// 34. accept_arbiter_rotation fails before time-lock expires
928+
#[test]
929+
#[should_panic]
930+
fn test_accept_arbiter_rotation_locked_panics() {
931+
let env = Env::default();
932+
env.mock_all_auths();
933+
let depositor = Address::generate(&env);
934+
let recipient = Address::generate(&env);
935+
let arbiter = Address::generate(&env);
936+
let new_arbiter = Address::generate(&env);
937+
let (token_id, _) = create_token(&env, &depositor);
938+
StellarAssetClient::new(&env, &token_id).mint(&depositor, &1_000);
939+
let contract_id = env.register_contract(None, EscrowContract);
940+
let client = EscrowContractClient::new(&env, &contract_id);
941+
let expiry = env.ledger().timestamp() + EXPIRY_OFFSET;
942+
client.initialize(&depositor, &recipient, &arbiter, &token_id, &500, &expiry);
943+
client.propose_new_arbiter(&depositor, &new_arbiter);
944+
// Try to accept before 24 hours have passed — should panic
945+
client.accept_arbiter_rotation();
946+
}
947+
948+
// 35. accept_arbiter_rotation succeeds after time-lock expires
949+
#[test]
950+
fn test_accept_arbiter_rotation_succeeds_after_delay() {
951+
let env = Env::default();
952+
env.mock_all_auths();
953+
let depositor = Address::generate(&env);
954+
let recipient = Address::generate(&env);
955+
let arbiter = Address::generate(&env);
956+
let new_arbiter = Address::generate(&env);
957+
let (token_id, _) = create_token(&env, &depositor);
958+
StellarAssetClient::new(&env, &token_id).mint(&depositor, &1_000);
959+
let contract_id = env.register_contract(None, EscrowContract);
960+
let client = EscrowContractClient::new(&env, &contract_id);
961+
let now = env.ledger().timestamp();
962+
let expiry = now + EXPIRY_OFFSET;
963+
client.initialize(&depositor, &recipient, &arbiter, &token_id, &500, &expiry);
964+
client.propose_new_arbiter(&depositor, &new_arbiter);
965+
// Advance time by 24 hours + 1 second
966+
env.ledger()
967+
.with_mut(|li| li.timestamp = now + 86_401);
968+
client.accept_arbiter_rotation();
969+
// Verify the new arbiter is now active
970+
let state = client.get_state();
971+
assert_eq!(state.arbiter, new_arbiter);
972+
}
973+
974+
// 36. accept_arbiter_rotation panics without pending rotation
975+
#[test]
976+
#[should_panic]
977+
fn test_accept_arbiter_rotation_no_pending_panics() {
978+
let env = Env::default();
979+
env.mock_all_auths();
980+
let depositor = Address::generate(&env);
981+
let recipient = Address::generate(&env);
982+
let arbiter = Address::generate(&env);
983+
let (token_id, _) = create_token(&env, &depositor);
984+
StellarAssetClient::new(&env, &token_id).mint(&depositor, &1_000);
985+
let contract_id = env.register_contract(None, EscrowContract);
986+
let client = EscrowContractClient::new(&env, &contract_id);
987+
let expiry = env.ledger().timestamp() + EXPIRY_OFFSET;
988+
client.initialize(&depositor, &recipient, &arbiter, &token_id, &500, &expiry);
989+
// Try to accept without proposing — should panic
990+
client.accept_arbiter_rotation();
991+
}
992+
993+
// 37. arbiter rotation enables recovery from compromised key
994+
#[test]
995+
fn test_arbiter_rotation_recovery_flow() {
996+
let env = Env::default();
997+
env.mock_all_auths();
998+
let depositor = Address::generate(&env);
999+
let recipient = Address::generate(&env);
1000+
let compromised_arbiter = Address::generate(&env);
1001+
let trusted_arbiter = Address::generate(&env);
1002+
let (token_id, _) = create_token(&env, &depositor);
1003+
StellarAssetClient::new(&env, &token_id).mint(&depositor, &1_000);
1004+
let contract_id = env.register_contract(None, EscrowContract);
1005+
let client = EscrowContractClient::new(&env, &contract_id);
1006+
let now = env.ledger().timestamp();
1007+
let expiry = now + EXPIRY_OFFSET;
1008+
client.initialize(
1009+
&depositor,
1010+
&recipient,
1011+
&compromised_arbiter,
1012+
&token_id,
1013+
&500,
1014+
&expiry,
1015+
);
1016+
// Propose rotation to trusted arbiter
1017+
client.propose_new_arbiter(&depositor, &trusted_arbiter);
1018+
// Wait for time-lock to expire
1019+
env.ledger()
1020+
.with_mut(|li| li.timestamp = now + 86_401);
1021+
// Accept rotation
1022+
client.accept_arbiter_rotation();
1023+
// Verify the trusted arbiter is now active
1024+
let state = client.get_state();
1025+
assert_eq!(
1026+
state.arbiter, trusted_arbiter,
1027+
"arbiter should be rotated to trusted address"
1028+
);
1029+
}
8651030
}

0 commit comments

Comments
 (0)