diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 12261c591..4f661b567 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -64,6 +64,15 @@ type Config struct { EscrowContractID string ProgramEscrowContractID string TokenContractID string + + // Sandbox mode: mirrors selected contract operations to separate sandbox + // contract instances for testing new features against real-ish data. + SandboxEnabled bool + SandboxEscrowContractID string // Sandbox escrow contract address + SandboxProgramEscrowContractID string // Sandbox program escrow contract address + SandboxShadowedOperations string // Comma-separated operations to shadow (e.g. "lock_funds,release_funds") + SandboxSourceSecret string // Separate keypair for sandbox transactions + SandboxMaxConcurrentShadows int // Max concurrent shadow goroutines (default: 10) } func Load() Config { @@ -123,6 +132,14 @@ func Load() Config { EscrowContractID: getEnv("ESCROW_CONTRACT_ID", ""), ProgramEscrowContractID: getEnv("PROGRAM_ESCROW_CONTRACT_ID", ""), TokenContractID: getEnv("TOKEN_CONTRACT_ID", ""), + + // Sandbox mode + SandboxEnabled: getEnvBool("SANDBOX_ENABLED", false), + SandboxEscrowContractID: getEnv("SANDBOX_ESCROW_CONTRACT_ID", ""), + SandboxProgramEscrowContractID: getEnv("SANDBOX_PROGRAM_ESCROW_CONTRACT_ID", ""), + SandboxShadowedOperations: getEnv("SANDBOX_SHADOWED_OPERATIONS", "lock_funds,release_funds,refund,single_payout,batch_payout"), + SandboxSourceSecret: getEnv("SANDBOX_SOURCE_SECRET", ""), + SandboxMaxConcurrentShadows: getEnvInt("SANDBOX_MAX_CONCURRENT_SHADOWS", 10), } } @@ -153,6 +170,18 @@ func getEnv(key, fallback string) string { return v } +func getEnvInt(key string, fallback int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} + func getEnvBool(key string, fallback bool) bool { v := strings.ToLower(strings.TrimSpace(os.Getenv(key))) if v == "" { diff --git a/backend/internal/soroban/sandbox.go b/backend/internal/soroban/sandbox.go new file mode 100644 index 000000000..eead71e4e --- /dev/null +++ b/backend/internal/soroban/sandbox.go @@ -0,0 +1,238 @@ +package soroban + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" +) + +// SandboxConfig holds configuration for sandbox shadow testing. +type SandboxConfig struct { + Enabled bool + EscrowSandboxContractID string + ProgramSandboxContractID string + ShadowedOperations []string // e.g. ["lock_funds", "release_funds", "refund"] + SandboxSourceSecret string // Separate keypair to avoid tx_bad_seq with production + MaxConcurrentShadows int // Bounds goroutine count (default: 10) +} + +// SandboxManager mirrors selected contract operations to sandbox contract +// instances for testing new features against real-ish data flow. Shadow +// operations run asynchronously and never block or affect production calls. +type SandboxManager struct { + config SandboxConfig + escrow *EscrowContract + program *ProgramEscrowContract + shadowOps map[string]bool + sem chan struct{} +} + +// NewSandboxManager creates a SandboxManager with its own contract clients +// pointing at sandbox addresses and a separate TransactionBuilder. Returns an +// error if enabled but required configuration is missing. +func NewSandboxManager(client *Client, cfg SandboxConfig) (*SandboxManager, error) { + if !cfg.Enabled { + return &SandboxManager{config: cfg}, nil + } + + if cfg.EscrowSandboxContractID == "" { + return nil, fmt.Errorf("sandbox: SANDBOX_ESCROW_CONTRACT_ID is required when sandbox is enabled") + } + if cfg.ProgramSandboxContractID == "" { + return nil, fmt.Errorf("sandbox: SANDBOX_PROGRAM_ESCROW_CONTRACT_ID is required when sandbox is enabled") + } + if cfg.SandboxSourceSecret == "" { + return nil, fmt.Errorf("sandbox: SANDBOX_SOURCE_SECRET is required when sandbox is enabled") + } + + maxConcurrent := cfg.MaxConcurrentShadows + if maxConcurrent <= 0 { + maxConcurrent = 10 + } + + // Create a separate TransactionBuilder with its own keypair so sandbox + // transactions don't conflict with production sequence numbers. + txBuilder, err := NewTransactionBuilder(client, cfg.SandboxSourceSecret, DefaultRetryConfig()) + if err != nil { + return nil, fmt.Errorf("sandbox: failed to create transaction builder: %w", err) + } + + // Build the operation lookup set. + shadowOps := make(map[string]bool, len(cfg.ShadowedOperations)) + for _, op := range cfg.ShadowedOperations { + op = strings.TrimSpace(op) + if op != "" { + shadowOps[op] = true + } + } + + slog.Info("sandbox mode enabled", + "escrow_contract", cfg.EscrowSandboxContractID, + "program_contract", cfg.ProgramSandboxContractID, + "shadowed_operations", cfg.ShadowedOperations, + "max_concurrent", maxConcurrent, + ) + + return &SandboxManager{ + config: cfg, + escrow: NewEscrowContract(client, txBuilder, cfg.EscrowSandboxContractID), + program: NewProgramEscrowContract(client, txBuilder, cfg.ProgramSandboxContractID), + shadowOps: shadowOps, + sem: make(chan struct{}, maxConcurrent), + }, nil +} + +// shouldShadow returns true if the given operation is configured for shadowing. +func (sm *SandboxManager) shouldShadow(operation string) bool { + if !sm.config.Enabled { + return false + } + return sm.shadowOps[operation] +} + +// acquireSemaphore tries to acquire a semaphore slot without blocking. +// Returns false if the sandbox is at capacity. +func (sm *SandboxManager) acquireSemaphore() bool { + select { + case sm.sem <- struct{}{}: + return true + default: + return false + } +} + +func (sm *SandboxManager) releaseSemaphore() { + <-sm.sem +} + +// logShadowResult emits a structured log entry for a completed shadow operation. +func logShadowResult(operation string, start time.Time, err error) { + elapsed := time.Since(start) + if err != nil { + slog.Warn("sandbox shadow failed", + "sandbox", true, + "operation", operation, + "duration_ms", elapsed.Milliseconds(), + "error", err, + ) + return + } + slog.Info("sandbox shadow succeeded", + "sandbox", true, + "operation", operation, + "duration_ms", elapsed.Milliseconds(), + ) +} + +// ShadowLockFunds mirrors a lock_funds call to the sandbox escrow contract. +func (sm *SandboxManager) ShadowLockFunds(ctx context.Context, depositor string, bountyID uint64, amount int64, deadline int64) { + const op = "lock_funds" + if !sm.shouldShadow(op) { + return + } + if !sm.acquireSemaphore() { + slog.Warn("sandbox shadow skipped: at capacity", "sandbox", true, "operation", op) + return + } + + // Detach from the HTTP request lifecycle so cancellation of the parent + // context does not abort the shadow operation. + shadowCtx := context.WithoutCancel(ctx) + + go func() { + defer sm.releaseSemaphore() + start := time.Now() + _, err := sm.escrow.LockFunds(shadowCtx, depositor, bountyID, amount, deadline) + logShadowResult(op, start, err) + }() +} + +// ShadowReleaseFunds mirrors a release_funds call to the sandbox escrow contract. +func (sm *SandboxManager) ShadowReleaseFunds(ctx context.Context, bountyID uint64, contributor string) { + const op = "release_funds" + if !sm.shouldShadow(op) { + return + } + if !sm.acquireSemaphore() { + slog.Warn("sandbox shadow skipped: at capacity", "sandbox", true, "operation", op) + return + } + + shadowCtx := context.WithoutCancel(ctx) + + go func() { + defer sm.releaseSemaphore() + start := time.Now() + _, err := sm.escrow.ReleaseFunds(shadowCtx, bountyID, contributor) + logShadowResult(op, start, err) + }() +} + +// ShadowRefund mirrors a refund call to the sandbox escrow contract. +func (sm *SandboxManager) ShadowRefund(ctx context.Context, bountyID uint64) { + const op = "refund" + if !sm.shouldShadow(op) { + return + } + if !sm.acquireSemaphore() { + slog.Warn("sandbox shadow skipped: at capacity", "sandbox", true, "operation", op) + return + } + + shadowCtx := context.WithoutCancel(ctx) + + go func() { + defer sm.releaseSemaphore() + start := time.Now() + _, err := sm.escrow.Refund(shadowCtx, bountyID) + logShadowResult(op, start, err) + }() +} + +// ShadowSinglePayout mirrors a single_payout call to the sandbox program contract. +func (sm *SandboxManager) ShadowSinglePayout(ctx context.Context, recipient string, amount int64) { + const op = "single_payout" + if !sm.shouldShadow(op) { + return + } + if !sm.acquireSemaphore() { + slog.Warn("sandbox shadow skipped: at capacity", "sandbox", true, "operation", op) + return + } + + shadowCtx := context.WithoutCancel(ctx) + + go func() { + defer sm.releaseSemaphore() + start := time.Now() + _, err := sm.program.SinglePayout(shadowCtx, recipient, amount) + logShadowResult(op, start, err) + }() +} + +// ShadowBatchPayout mirrors a batch_payout call to the sandbox program contract. +func (sm *SandboxManager) ShadowBatchPayout(ctx context.Context, payouts []PayoutItem) { + const op = "batch_payout" + if !sm.shouldShadow(op) { + return + } + if !sm.acquireSemaphore() { + slog.Warn("sandbox shadow skipped: at capacity", "sandbox", true, "operation", op) + return + } + + // Copy the slice to avoid races if the caller mutates it after returning. + items := make([]PayoutItem, len(payouts)) + copy(items, payouts) + + shadowCtx := context.WithoutCancel(ctx) + + go func() { + defer sm.releaseSemaphore() + start := time.Now() + _, err := sm.program.BatchPayout(shadowCtx, items) + logShadowResult(op, start, err) + }() +} diff --git a/backend/internal/soroban/sandbox_test.go b/backend/internal/soroban/sandbox_test.go new file mode 100644 index 000000000..49c3a9099 --- /dev/null +++ b/backend/internal/soroban/sandbox_test.go @@ -0,0 +1,151 @@ +package soroban + +import ( + "context" + "testing" +) + +func TestShouldShadow_EnabledOperations(t *testing.T) { + sm := &SandboxManager{ + config: SandboxConfig{Enabled: true}, + shadowOps: map[string]bool{ + "lock_funds": true, + "release_funds": true, + }, + sem: make(chan struct{}, 10), + } + + if !sm.shouldShadow("lock_funds") { + t.Error("expected lock_funds to be shadowed") + } + if !sm.shouldShadow("release_funds") { + t.Error("expected release_funds to be shadowed") + } + if sm.shouldShadow("refund") { + t.Error("expected refund to NOT be shadowed") + } + if sm.shouldShadow("unknown_op") { + t.Error("expected unknown_op to NOT be shadowed") + } +} + +func TestShouldShadow_DisabledGlobal(t *testing.T) { + sm := &SandboxManager{ + config: SandboxConfig{Enabled: false}, + shadowOps: map[string]bool{ + "lock_funds": true, + }, + sem: make(chan struct{}, 10), + } + + if sm.shouldShadow("lock_funds") { + t.Error("expected lock_funds to NOT be shadowed when sandbox is disabled") + } +} + +func TestShadowDisabledNoOp(t *testing.T) { + // A disabled SandboxManager (created via NewSandboxManager with Enabled=false) + // should have nil contract clients. Shadow methods must return immediately + // without panicking. + sm := &SandboxManager{ + config: SandboxConfig{Enabled: false}, + sem: make(chan struct{}, 1), + } + + // These must not panic even though escrow/program are nil. + sm.ShadowLockFunds(context.Background(), "GABC", 1, 1000, 0) + sm.ShadowReleaseFunds(context.Background(), 1, "GABC") + sm.ShadowRefund(context.Background(), 1) + sm.ShadowSinglePayout(context.Background(), "GABC", 500) + sm.ShadowBatchPayout(context.Background(), []PayoutItem{{Recipient: "GABC", Amount: 100}}) +} + +func TestSemaphoreBound(t *testing.T) { + sm := &SandboxManager{ + config: SandboxConfig{Enabled: true}, + shadowOps: map[string]bool{ + "lock_funds": true, + }, + sem: make(chan struct{}, 2), + } + + // Fill the semaphore manually. + sm.sem <- struct{}{} + sm.sem <- struct{}{} + + // acquireSemaphore should return false when full. + if sm.acquireSemaphore() { + t.Error("expected acquireSemaphore to return false when at capacity") + } + + // Release one slot. + sm.releaseSemaphore() + if !sm.acquireSemaphore() { + t.Error("expected acquireSemaphore to succeed after releasing a slot") + } + + // Cleanup. + sm.releaseSemaphore() + sm.releaseSemaphore() +} + +func TestShadowDetachedContext(t *testing.T) { + // Verify that shouldShadow works correctly even when parent context is + // already cancelled — the shouldShadow check itself doesn't depend on ctx. + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + sm := &SandboxManager{ + config: SandboxConfig{Enabled: false}, + sem: make(chan struct{}, 1), + } + + // Should not panic with cancelled context; returns early because disabled. + sm.ShadowLockFunds(ctx, "GABC", 1, 1000, 0) +} + +func TestNewSandboxManager_Disabled(t *testing.T) { + sm, err := NewSandboxManager(nil, SandboxConfig{Enabled: false}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sm.config.Enabled { + t.Error("expected sandbox to be disabled") + } +} + +func TestNewSandboxManager_MissingEscrowID(t *testing.T) { + _, err := NewSandboxManager(nil, SandboxConfig{ + Enabled: true, + EscrowSandboxContractID: "", + ProgramSandboxContractID: "CDEF", + SandboxSourceSecret: "SNOTAREALSECRET", + }) + if err == nil { + t.Error("expected error when escrow contract ID is missing") + } +} + +func TestNewSandboxManager_MissingProgramID(t *testing.T) { + _, err := NewSandboxManager(nil, SandboxConfig{ + Enabled: true, + EscrowSandboxContractID: "CABC", + ProgramSandboxContractID: "", + SandboxSourceSecret: "SNOTAREALSECRET", + }) + if err == nil { + t.Error("expected error when program contract ID is missing") + } +} + +func TestNewSandboxManager_MissingSourceSecret(t *testing.T) { + _, err := NewSandboxManager(nil, SandboxConfig{ + Enabled: true, + EscrowSandboxContractID: "CABC", + ProgramSandboxContractID: "CDEF", + SandboxSourceSecret: "", + }) + if err == nil { + t.Error("expected error when source secret is missing") + } +} diff --git a/contracts/bounty_escrow/contracts/escrow/src/lib.rs b/contracts/bounty_escrow/contracts/escrow/src/lib.rs index f422e3d79..8f8daa4ff 100644 --- a/contracts/bounty_escrow/contracts/escrow/src/lib.rs +++ b/contracts/bounty_escrow/contracts/escrow/src/lib.rs @@ -2230,6 +2230,61 @@ impl BountyEscrowContract { .persistent() .set(&DataKey::Escrow(bounty_id), &escrow); + // INTERACTION: external token transfer (CEI — state updated above) + // INTERACTION: token transfer + let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); + let client = token::Client::new(&env, &token_addr); + client.transfer( + &env.current_contract_address(), + &schedule.recipient, + &schedule.amount, + ); + + // Update schedule metadata + let now = env.ledger().timestamp(); + schedule.released = true; + schedule.released_at = Some(now); + schedule.released_by = Some(admin.clone()); + env.storage() + .persistent() + .set(&DataKey::ReleaseSchedule(bounty_id, schedule_id), &schedule); + + // Add to history + let history_entry = EscrowReleaseHistory { + schedule_id, + bounty_id, + amount: schedule.amount, + recipient: schedule.recipient.clone(), + released_at: now, + released_by: admin.clone(), + release_type: ReleaseType::Manual, + }; + + let mut history: Vec = env + .storage() + .persistent() + .get(&DataKey::ReleaseHistory(bounty_id)) + .unwrap_or(vec![&env]); + history.push_back(history_entry); + env.storage() + .persistent() + .set(&DataKey::ReleaseHistory(bounty_id), &history); + + // Emit events + events::emit_schedule_released( + &env, + events::ScheduleReleased { + bounty_id, + schedule_id, + amount: schedule.amount, + recipient: schedule.recipient.clone(), + released_at: now, + released_by: admin.clone(), + release_type: ReleaseType::Manual, + }, + ); + + let timestamp = env.ledger().timestamp(); events::emit_funds_released( &env, FundsReleased { @@ -2266,6 +2321,183 @@ impl BountyEscrowContract { return Err(Error::FundsNotLocked); } + // Block refund if there is a pending claim (Issue #391 fix) + if env + .storage() + .persistent() + .has(&DataKey::PendingClaim(bounty_id)) + { + let claim: ClaimRecord = env + .storage() + .persistent() + .get(&DataKey::PendingClaim(bounty_id)) + .unwrap(); + if !claim.claimed { + return Err(Error::ClaimPending); + } + } + + let now = env.ledger().timestamp(); + let approval_key = DataKey::RefundApproval(bounty_id); + let approval: Option = env.storage().persistent().get(&approval_key); + + // Refund is allowed if: + // 1. Deadline has passed (returns full amount to depositor) + // 2. An administrative approval exists (can be early, partial, and to custom recipient) + if now < escrow.deadline && approval.is_none() { + return Err(Error::DeadlineNotPassed); + } + + let (refund_amount, refund_to, is_full) = if let Some(app) = approval.clone() { + let full = app.mode == RefundMode::Full || app.amount >= escrow.remaining_amount; + (app.amount, app.recipient, full) + } else { + // Standard refund after deadline + (escrow.remaining_amount, escrow.depositor.clone(), true) + }; + + if refund_amount <= 0 || refund_amount > escrow.remaining_amount { + return Err(Error::InvalidAmount); + } + + // EFFECTS: update state before external call (CEI) + invariants::assert_escrow(&env, &escrow); + // Update escrow state: subtract the amount exactly refunded + escrow.remaining_amount = escrow.remaining_amount.checked_sub(refund_amount).unwrap(); + if is_full || escrow.remaining_amount == 0 { + escrow.status = EscrowStatus::Refunded; + } else { + escrow.status = EscrowStatus::PartiallyRefunded; + } + + // Add to refund history + escrow.refund_history.push_back(RefundRecord { + amount: refund_amount, + recipient: refund_to.clone(), + timestamp: now, + mode: if is_full { + RefundMode::Full + } else { + RefundMode::Partial + }, + }); + + // Save updated escrow + env.storage() + .persistent() + .set(&DataKey::Escrow(bounty_id), &escrow); + + // Remove approval after successful execution + if approval.is_some() { + env.storage().persistent().remove(&approval_key); + } + + // INTERACTION: external token transfer is last + let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); + let client = token::Client::new(&env, &token_addr); + client.transfer(&env.current_contract_address(), &refund_to, &refund_amount); + + emit_funds_refunded( + &env, + FundsRefunded { + version: EVENT_VERSION_V2, + bounty_id, + amount: refund_amount, + refund_to: refund_to.clone(), + timestamp: now, + }, + ); + Self::record_receipt( + &env, + CriticalOperationOutcome::Refunded, + bounty_id, + refund_amount, + refund_to.clone(), + ); + + // INV-2: Verify aggregate balance matches token balance after refund + multitoken_invariants::assert_after_disbursement(&env); + + // GUARD: release reentrancy lock + reentrancy_guard::release(&env); + Ok(()) + } + + /// Sets or clears the anonymous resolver address. + /// Only the admin can call this. The resolver is the trusted entity that + /// resolves anonymous escrow refunds via `refund_resolved`. + pub fn set_anonymous_resolver(env: Env, resolver: Option
) -> Result<(), Error> { + if !env.storage().instance().has(&DataKey::Admin) { + return Err(Error::NotInitialized); + } + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + match resolver { + Some(addr) => env + .storage() + .instance() + .set(&DataKey::AnonymousResolver, &addr), + None => env.storage().instance().remove(&DataKey::AnonymousResolver), + } + /// Set the anonymous resolver address (admin only). + /// The resolver is authorized to call `refund_resolved` for anonymous escrows. + /// Pass `None` to clear/unset the resolver. + pub fn set_anonymous_resolver(env: Env, resolver: Option
) -> Result<(), Error> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + admin.require_auth(); + + if let Some(r) = resolver { + env.storage() + .instance() + .set(&DataKey::AnonymousResolver, &r); + } else { + env.storage().instance().remove(&DataKey::AnonymousResolver); + } + + Ok(()) + } + + /// Refund an anonymous escrow to a resolved recipient. + /// Only the configured anonymous resolver can call this; they resolve the depositor + /// commitment off-chain and pass the recipient address (signed instruction pattern). + pub fn refund_resolved(env: Env, bounty_id: u64, recipient: Address) -> Result<(), Error> { + if Self::check_paused(&env, symbol_short!("refund")) { + return Err(Error::FundsPaused); + } + + let resolver: Address = env + .storage() + .instance() + .get(&DataKey::AnonymousResolver) + .ok_or(Error::AnonymousResolverNotSet)?; + resolver.require_auth(); + + if !env + .storage() + .persistent() + .has(&DataKey::EscrowAnon(bounty_id)) + { + return Err(Error::NotAnonymousEscrow); + } + + reentrancy_guard::acquire(&env); + + let mut anon: AnonymousEscrow = env + .storage() + .persistent() + .get(&DataKey::EscrowAnon(bounty_id)) + .unwrap(); + + if escrow.status != EscrowStatus::Locked && escrow.status != EscrowStatus::PartiallyRefunded + { + return Err(Error::FundsNotLocked); + } + // GUARD 1: Block refund if there is a pending claim (Issue #391 fix) if env .storage() @@ -3801,4 +4033,7 @@ mod test_deadline_variants; #[cfg(test)] mod test_query_filters; #[cfg(test)] +mod test_sandbox; +mod test_receipts; +#[cfg(test)] mod test_status_transitions; diff --git a/contracts/bounty_escrow/contracts/escrow/src/test_sandbox.rs b/contracts/bounty_escrow/contracts/escrow/src/test_sandbox.rs new file mode 100644 index 000000000..de3922817 --- /dev/null +++ b/contracts/bounty_escrow/contracts/escrow/src/test_sandbox.rs @@ -0,0 +1,294 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, MockAuth, MockAuthInvoke}, + token, Address, Env, IntoVal, +}; + +fn create_token_contract<'a>( + e: &Env, + admin: &Address, +) -> (token::Client<'a>, token::StellarAssetClient<'a>) { + let contract_address = e + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + ( + token::Client::new(e, &contract_address), + token::StellarAssetClient::new(e, &contract_address), + ) +} + +fn create_escrow_contract<'a>(e: &Env) -> BountyEscrowContractClient<'a> { + let contract_id = e.register_contract(None, BountyEscrowContract); + BountyEscrowContractClient::new(e, &contract_id) +} + +/// Two contract instances deployed from the same WASM are fully independent: +/// locking funds on instance A must not affect instance B's balance. +#[test] +fn test_sandbox_instance_isolation_lock() { + let env = Env::default(); + + let admin_a = Address::generate(&env); + let admin_b = Address::generate(&env); + let depositor = Address::generate(&env); + + let (token_client, token_admin) = create_token_contract(&env, &admin_a); + + // Deploy two independent escrow instances (prod + sandbox). + let prod = create_escrow_contract(&env); + let sandbox = create_escrow_contract(&env); + + // Initialize each with a different admin. + env.mock_auths(&[MockAuth { + address: &admin_a, + invoke: &MockAuthInvoke { + contract: &prod.address, + fn_name: "init", + args: (&admin_a, &token_client.address).into_val(&env), + sub_invokes: &[], + }, + }]); + prod.init(&admin_a, &token_client.address); + + env.mock_auths(&[MockAuth { + address: &admin_b, + invoke: &MockAuthInvoke { + contract: &sandbox.address, + fn_name: "init", + args: (&admin_b, &token_client.address).into_val(&env), + sub_invokes: &[], + }, + }]); + sandbox.init(&admin_b, &token_client.address); + + // Mint tokens to the depositor. + env.mock_auths(&[MockAuth { + address: &admin_a, + invoke: &MockAuthInvoke { + contract: &token_client.address, + fn_name: "mint", + args: (depositor.clone(), 20000i128).into_val(&env), + sub_invokes: &[], + }, + }]); + token_admin.mint(&depositor, &20000); + + // Lock funds on the PROD instance only. + let bounty_id = 1u64; + let amount = 5000i128; + let deadline = env.ledger().timestamp() + 86400; + + env.mock_auths(&[MockAuth { + address: &depositor, + invoke: &MockAuthInvoke { + contract: &prod.address, + fn_name: "lock_funds", + args: (depositor.clone(), bounty_id, amount, deadline).into_val(&env), + sub_invokes: &[MockAuthInvoke { + contract: &token_client.address, + fn_name: "transfer", + args: (depositor.clone(), prod.address.clone(), amount).into_val(&env), + sub_invokes: &[], + }], + }, + }]); + prod.lock_funds(&depositor, &bounty_id, &amount, &deadline); + + // Prod balance increased; sandbox balance stays at zero. + assert_eq!(prod.get_balance(), amount); + assert_eq!(sandbox.get_balance(), 0); +} + +/// Operations on the sandbox instance don't affect the prod instance. +#[test] +fn test_sandbox_instance_isolation_release() { + let env = Env::default(); + + let admin_a = Address::generate(&env); + let admin_b = Address::generate(&env); + let depositor = Address::generate(&env); + let contributor = Address::generate(&env); + + let (token_client, token_admin) = create_token_contract(&env, &admin_a); + + let prod = create_escrow_contract(&env); + let sandbox = create_escrow_contract(&env); + + // Initialize both instances. + env.mock_auths(&[MockAuth { + address: &admin_a, + invoke: &MockAuthInvoke { + contract: &prod.address, + fn_name: "init", + args: (&admin_a, &token_client.address).into_val(&env), + sub_invokes: &[], + }, + }]); + prod.init(&admin_a, &token_client.address); + + env.mock_auths(&[MockAuth { + address: &admin_b, + invoke: &MockAuthInvoke { + contract: &sandbox.address, + fn_name: "init", + args: (&admin_b, &token_client.address).into_val(&env), + sub_invokes: &[], + }, + }]); + sandbox.init(&admin_b, &token_client.address); + + // Mint and lock on BOTH instances. + env.mock_auths(&[MockAuth { + address: &admin_a, + invoke: &MockAuthInvoke { + contract: &token_client.address, + fn_name: "mint", + args: (depositor.clone(), 20000i128).into_val(&env), + sub_invokes: &[], + }, + }]); + token_admin.mint(&depositor, &20000); + + let bounty_id = 42u64; + let amount = 3000i128; + let deadline = env.ledger().timestamp() + 86400; + + // Lock on prod. + env.mock_auths(&[MockAuth { + address: &depositor, + invoke: &MockAuthInvoke { + contract: &prod.address, + fn_name: "lock_funds", + args: (depositor.clone(), bounty_id, amount, deadline).into_val(&env), + sub_invokes: &[MockAuthInvoke { + contract: &token_client.address, + fn_name: "transfer", + args: (depositor.clone(), prod.address.clone(), amount).into_val(&env), + sub_invokes: &[], + }], + }, + }]); + prod.lock_funds(&depositor, &bounty_id, &amount, &deadline); + + // Lock on sandbox. + env.mock_auths(&[MockAuth { + address: &depositor, + invoke: &MockAuthInvoke { + contract: &sandbox.address, + fn_name: "lock_funds", + args: (depositor.clone(), bounty_id, amount, deadline).into_val(&env), + sub_invokes: &[MockAuthInvoke { + contract: &token_client.address, + fn_name: "transfer", + args: (depositor.clone(), sandbox.address.clone(), amount).into_val(&env), + sub_invokes: &[], + }], + }, + }]); + sandbox.lock_funds(&depositor, &bounty_id, &amount, &deadline); + + assert_eq!(prod.get_balance(), amount); + assert_eq!(sandbox.get_balance(), amount); + + // Release on sandbox only. + env.mock_auths(&[MockAuth { + address: &admin_b, + invoke: &MockAuthInvoke { + contract: &sandbox.address, + fn_name: "release_funds", + args: (bounty_id, contributor.clone()).into_val(&env), + sub_invokes: &[MockAuthInvoke { + contract: &token_client.address, + fn_name: "transfer", + args: (sandbox.address.clone(), contributor.clone(), amount).into_val(&env), + sub_invokes: &[], + }], + }, + }]); + sandbox.release_funds(&bounty_id, &contributor); + + // Sandbox balance is now 0; prod balance is unchanged. + assert_eq!(sandbox.get_balance(), 0); + assert_eq!(prod.get_balance(), amount); +} + +/// Both instances can run the same bounty ID concurrently without conflict. +#[test] +fn test_sandbox_same_bounty_id_no_conflict() { + let env = Env::default(); + + let admin = Address::generate(&env); + let depositor = Address::generate(&env); + + let (token_client, token_admin) = create_token_contract(&env, &admin); + + let instance_a = create_escrow_contract(&env); + let instance_b = create_escrow_contract(&env); + + // Initialize both with the same admin (allowed — different contract addresses). + for instance in [&instance_a, &instance_b] { + env.mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &instance.address, + fn_name: "init", + args: (&admin, &token_client.address).into_val(&env), + sub_invokes: &[], + }, + }]); + instance.init(&admin, &token_client.address); + } + + env.mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &token_client.address, + fn_name: "mint", + args: (depositor.clone(), 50000i128).into_val(&env), + sub_invokes: &[], + }, + }]); + token_admin.mint(&depositor, &50000); + + // Use the SAME bounty ID on both instances with different amounts. + let bounty_id = 99u64; + let deadline = env.ledger().timestamp() + 86400; + + env.mock_auths(&[MockAuth { + address: &depositor, + invoke: &MockAuthInvoke { + contract: &instance_a.address, + fn_name: "lock_funds", + args: (depositor.clone(), bounty_id, 1000i128, deadline).into_val(&env), + sub_invokes: &[MockAuthInvoke { + contract: &token_client.address, + fn_name: "transfer", + args: (depositor.clone(), instance_a.address.clone(), 1000i128).into_val(&env), + sub_invokes: &[], + }], + }, + }]); + instance_a.lock_funds(&depositor, &bounty_id, &1000, &deadline); + + env.mock_auths(&[MockAuth { + address: &depositor, + invoke: &MockAuthInvoke { + contract: &instance_b.address, + fn_name: "lock_funds", + args: (depositor.clone(), bounty_id, 7000i128, deadline).into_val(&env), + sub_invokes: &[MockAuthInvoke { + contract: &token_client.address, + fn_name: "transfer", + args: (depositor.clone(), instance_b.address.clone(), 7000i128).into_val(&env), + sub_invokes: &[], + }], + }, + }]); + instance_b.lock_funds(&depositor, &bounty_id, &7000, &deadline); + + // Each instance tracks its own balance independently. + assert_eq!(instance_a.get_balance(), 1000); + assert_eq!(instance_b.get_balance(), 7000); +} diff --git a/contracts/scripts/deploy-sandbox.sh b/contracts/scripts/deploy-sandbox.sh new file mode 100755 index 000000000..8adf75f4c --- /dev/null +++ b/contracts/scripts/deploy-sandbox.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# ============================================================================== +# Grainlify - Sandbox Contract Deployment Script +# ============================================================================== +# Deploys sandbox instances of escrow contracts for shadow testing. +# Uses the same WASM as production but deploys to separate contract addresses. +# +# USAGE: +# ./scripts/deploy-sandbox.sh [options] +# +# OPTIONS: +# -n, --network Network to deploy to (testnet|mainnet) [default: testnet] +# -i, --identity Deployer identity name [default: sandbox-deployer] +# --escrow-wasm Path to escrow WASM [default: auto-detected] +# --program-wasm Path to program escrow WASM [default: auto-detected] +# --dry-run Simulate deployment without executing +# -v, --verbose Enable verbose output +# -h, --help Show this help message +# +# PREREQUISITES: +# 1. Create a sandbox deployer identity: +# stellar keys generate --global sandbox-deployer +# stellar keys fund sandbox-deployer --network testnet +# +# 2. Build contracts first: +# cd contracts && cargo build --release --target wasm32-unknown-unknown +# +# OUTPUT: +# Prints environment variables to add to your .env: +# SANDBOX_ESCROW_CONTRACT_ID=
+# SANDBOX_PROGRAM_ESCROW_CONTRACT_ID=
+# +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Source shared utilities +source "$SCRIPT_DIR/utils/common.sh" + +# ------------------------------------------------------------------------------ +# Defaults +# ------------------------------------------------------------------------------ + +NETWORK="testnet" +IDENTITY="sandbox-deployer" +ESCROW_WASM="" +PROGRAM_WASM="" +DRY_RUN="false" +VERBOSE="false" + +# Auto-detect WASM paths +WASM_DIR="$PROJECT_ROOT/../soroban/target/wasm32-unknown-unknown/release" + +# ------------------------------------------------------------------------------ +# Usage +# ------------------------------------------------------------------------------ + +show_usage() { + head -35 "$0" | grep -E "^#" | sed 's/^# \?//' + exit 0 +} + +# ------------------------------------------------------------------------------ +# Argument Parsing +# ------------------------------------------------------------------------------ + +while [[ $# -gt 0 ]]; do + case "$1" in + -n|--network) NETWORK="$2"; shift 2 ;; + -i|--identity) IDENTITY="$2"; shift 2 ;; + --escrow-wasm) ESCROW_WASM="$2"; shift 2 ;; + --program-wasm) PROGRAM_WASM="$2"; shift 2 ;; + --dry-run) DRY_RUN="true"; shift ;; + -v|--verbose) VERBOSE="true"; export VERBOSE; shift ;; + -h|--help) show_usage ;; + *) + log_error "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Auto-detect WASM files if not provided +if [[ -z "$ESCROW_WASM" ]]; then + ESCROW_WASM="$WASM_DIR/escrow.wasm" +fi +if [[ -z "$PROGRAM_WASM" ]]; then + PROGRAM_WASM="$WASM_DIR/program_escrow.wasm" +fi + +# ------------------------------------------------------------------------------ +# Validation +# ------------------------------------------------------------------------------ + +log_section "Sandbox Deployment" +log_info "Network: $NETWORK" +log_info "Identity: $IDENTITY" + +if [[ ! -f "$ESCROW_WASM" ]]; then + log_error "Escrow WASM not found: $ESCROW_WASM" + log_error "Build contracts first: cargo build --release --target wasm32-unknown-unknown" + exit 1 +fi + +if [[ ! -f "$PROGRAM_WASM" ]]; then + log_warn "Program escrow WASM not found: $PROGRAM_WASM" + log_warn "Only escrow sandbox will be deployed" +fi + +# ------------------------------------------------------------------------------ +# Deploy +# ------------------------------------------------------------------------------ + +SANDBOX_REGISTRY="$PROJECT_ROOT/deployments/sandbox-${NETWORK}.json" + +log_section "Deploying Sandbox Escrow Contract" + +DEPLOY_ARGS=(-n "$NETWORK" -i "$IDENTITY" -N "sandbox-escrow") +if [[ "$DRY_RUN" == "true" ]]; then + DEPLOY_ARGS+=(--dry-run) +fi +if [[ "$VERBOSE" == "true" ]]; then + DEPLOY_ARGS+=(-v) +fi + +ESCROW_CONTRACT_ID=$("$SCRIPT_DIR/deploy.sh" "$ESCROW_WASM" "${DEPLOY_ARGS[@]}" | tail -1) + +PROGRAM_CONTRACT_ID="" +if [[ -f "$PROGRAM_WASM" ]]; then + log_section "Deploying Sandbox Program Escrow Contract" + DEPLOY_ARGS_PROG=(-n "$NETWORK" -i "$IDENTITY" -N "sandbox-program-escrow") + if [[ "$DRY_RUN" == "true" ]]; then + DEPLOY_ARGS_PROG+=(--dry-run) + fi + if [[ "$VERBOSE" == "true" ]]; then + DEPLOY_ARGS_PROG+=(-v) + fi + PROGRAM_CONTRACT_ID=$("$SCRIPT_DIR/deploy.sh" "$PROGRAM_WASM" "${DEPLOY_ARGS_PROG[@]}" | tail -1) +fi + +# ------------------------------------------------------------------------------ +# Output +# ------------------------------------------------------------------------------ + +log_section "Sandbox Deployment Complete" +echo "" +echo "Add these to your .env file:" +echo "" +echo " SANDBOX_ENABLED=true" +echo " SANDBOX_ESCROW_CONTRACT_ID=$ESCROW_CONTRACT_ID" +if [[ -n "$PROGRAM_CONTRACT_ID" ]]; then + echo " SANDBOX_PROGRAM_ESCROW_CONTRACT_ID=$PROGRAM_CONTRACT_ID" +fi +echo " SANDBOX_SOURCE_SECRET=" +echo " SANDBOX_SHADOWED_OPERATIONS=lock_funds,release_funds,refund,single_payout,batch_payout" +echo " SANDBOX_MAX_CONCURRENT_SHADOWS=10" +echo "" +echo "Fund the sandbox source account:" +echo " stellar keys fund --network $NETWORK" +echo "" + +log_success "Sandbox deployment script completed"