diff --git a/backend/src/app.ts b/backend/src/app.ts index f5ad694..e4e9f1b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -20,19 +20,17 @@ import { register, METRICS_CONTENT_TYPE } from "./metrics/registry.js"; import { healthPayload } from "./health.js"; import { startWebhookDispatcher } from "./services/webhookDispatcher.js"; import { checkContentLength } from "./middleware/contentLength.js"; -import { createCorsOptions } from "./middleware/corsConfig.js"; +import { corsOriginsFromEnvironment, createCorsOptions } from "./middleware/corsConfig.js"; -export function createApp(): Express { +export function createApp(allowedOrigins?: string[]): Express { startWebhookDispatcher(); const app: Express = express(); app.set('json replacer', (_key: string, value: unknown) => typeof value === 'bigint' ? value.toString() : value); - const allowedOrigins = (process.env.CORS_ORIGINS ?? "http://localhost:5173") - .split(",") - .map((o) => o.trim()); + const resolvedOrigins = allowedOrigins ?? corsOriginsFromEnvironment(); app.use(helmet()); - app.use(cors(createCorsOptions(allowedOrigins))); + app.use(cors(createCorsOptions(resolvedOrigins))); app.use(requestId); // GET /metrics is mounted before logging and rate limiting so scrapes are never diff --git a/backend/src/contracts/lineproofClient.ts b/backend/src/contracts/lineproofClient.ts index 76f0825..db2429b 100644 --- a/backend/src/contracts/lineproofClient.ts +++ b/backend/src/contracts/lineproofClient.ts @@ -91,26 +91,34 @@ export const submitEnrollment = ( }; export const submitEscrowDeposit = ( - amount: number, + caller: string, + queueId: string, + amount: bigint, asset: string, ): Promise => { const sdk = requireWriter(); return submit(() => - sdk.escrow.deposit(sdk.contractIds.escrow!, amount, asset), + sdk.escrow.deposit(sdk.contractIds.escrow!, caller, queueId, amount, asset), ); }; export const submitQueueAdvance = ( queueContractId: string, + admin: string, batchSize: number, ): Promise => { const sdk = requireWriter(); - return submit(() => sdk.queue(queueContractId).advance(batchSize)); + return submit(() => + sdk.queue(queueContractId).advance(admin, batchSize), + ); }; -export const submitQueueClose = (queueContractId: string): Promise => { +export const submitQueueClose = ( + queueContractId: string, + admin: string, +): Promise => { const sdk = requireWriter(); - return submit(() => sdk.queue(queueContractId).close()); + return submit(() => sdk.queue(queueContractId).close(admin)); }; export async function readEnrollmentOnChain( diff --git a/contracts/.cargo/config.toml b/contracts/.cargo/config.toml new file mode 100644 index 0000000..ecdaa88 --- /dev/null +++ b/contracts/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target-dir = "target" + +[target.wasm32-unknown-unknown] +rustflags = ["-C", "target-feature=+reference-types"] diff --git a/contracts/.patched/soroban-env-host/src/budget/wasmi_helper.rs b/contracts/.patched/soroban-env-host/src/budget/wasmi_helper.rs index b976d4c..c7fb295 100644 --- a/contracts/.patched/soroban-env-host/src/budget/wasmi_helper.rs +++ b/contracts/.patched/soroban-env-host/src/budget/wasmi_helper.rs @@ -128,7 +128,7 @@ pub(crate) fn get_wasmi_config(budget: &Budget) -> Result (Env, Address) { +fn setup() -> (Env, Address, Symbol, Address) { let env = Env::default(); + env.mock_all_auths(); let admin = Address::generate(&env); - (env, admin) + let contract_id = env.register(EscrowImpl, ()); + let queue_id = Symbol::new(&env, "sneaker_drop"); + (env, admin, queue_id, contract_id) } fn make_config(env: &Env, admin: &Address) -> EscrowConfig { EscrowConfig { - queue_id: Symbol::new(env, "sneaker-drop"), + queue_id: Symbol::new(env, "sneaker_drop"), min_deposit: 100i128, max_deposit: 1000i128, hold_period_days: 30, @@ -20,10 +23,11 @@ fn make_config(env: &Env, admin: &Address) -> EscrowConfig { #[test] fn test_set_and_get_config() { - let (env, admin) = setup(); + let (env, admin, queue_id, contract_id) = setup(); + let client = EscrowImplClient::new(&env, &contract_id); let config = make_config(&env, &admin); - EscrowImpl::set_config(env.clone(), admin.clone(), config); - let loaded = EscrowImpl::get_config(env.clone(), Symbol::new(&env, "sneaker-drop")); + client.set_config(&admin, &config); + let loaded = client.get_config(&queue_id); assert_eq!(loaded.min_deposit, 100i128); assert_eq!(loaded.max_deposit, 1000i128); assert_eq!(loaded.hold_period_days, 30); @@ -31,258 +35,115 @@ fn test_set_and_get_config() { #[test] fn test_deposit_creates_record() { - let (env, admin) = setup(); - EscrowImpl::set_config(env.clone(), admin.clone(), make_config(&env, &admin)); - let user = Address::new(&env, &[9u8; 7]); - let asset = Address::new(&env, &[8u8; 7]); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 500i128, - asset.clone(), - ); + let (env, admin, queue_id, contract_id) = setup(); + let client = EscrowImplClient::new(&env, &contract_id); + client.set_config(&admin, &make_config(&env, &admin)); let user = Address::generate(&env); let asset = Address::generate(&env); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 500i128, - asset.clone(), - ); - let record = EscrowImpl::get_record(env.clone(), user.clone(), Symbol::new(&env, "sneaker-drop")).unwrap(); + client.deposit(&user, &queue_id, &500i128, &asset); + let record = client.get_record(&user, &queue_id).unwrap(); assert_eq!(record.amount, 500i128); assert!(matches!(record.status, EscrowStatus::Active)); } #[test] fn test_get_total_held_accumulates() { - let (env, admin) = setup(); - EscrowImpl::set_config(env.clone(), admin.clone(), make_config(&env, &admin)); - let user1 = Address::new(&env, &[9u8; 7]); - let user2 = Address::new(&env, &[12u8; 7]); - let asset = Address::new(&env, &[8u8; 7]); - EscrowImpl::deposit( - env.clone(), - user1.clone(), - Symbol::new(&env, "sneaker-drop"), - 500i128, - asset.clone(), - ); - EscrowImpl::deposit( - env.clone(), - user2.clone(), - Symbol::new(&env, "sneaker-drop"), - 300i128, - asset.clone(), - ); + let (env, admin, queue_id, contract_id) = setup(); + let client = EscrowImplClient::new(&env, &contract_id); + client.set_config(&admin, &make_config(&env, &admin)); let user1 = Address::generate(&env); let user2 = Address::generate(&env); let asset = Address::generate(&env); - EscrowImpl::deposit( - env.clone(), - user1.clone(), - Symbol::new(&env, "sneaker-drop"), - 500i128, - asset.clone(), - ); - EscrowImpl::deposit( - env.clone(), - user2.clone(), - Symbol::new(&env, "sneaker-drop"), - 300i128, - asset.clone(), - ); - let total = EscrowImpl::get_total_held(env.clone(), Symbol::new(&env, "sneaker-drop")); + client.deposit(&user1, &queue_id, &500i128, &asset); + client.deposit(&user2, &queue_id, &300i128, &asset); + let total = client.get_total_held(&queue_id); assert_eq!(total, 800i128); } #[test] fn test_release_changes_status() { - let (env, admin) = setup(); - EscrowImpl::set_config(env.clone(), admin.clone(), make_config(&env, &admin)); - let user = Address::new(&env, &[3u8; 7]); - let asset = Address::new(&env, &[8u8; 7]); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 500i128, - asset, - ); - EscrowImpl::release( - env.clone(), - admin.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - ); + let (env, admin, queue_id, contract_id) = setup(); + let client = EscrowImplClient::new(&env, &contract_id); + client.set_config(&admin, &make_config(&env, &admin)); let user = Address::generate(&env); let asset = Address::generate(&env); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 500i128, - asset, - ); - EscrowImpl::release( - env.clone(), - admin.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - ); - let record = EscrowImpl::get_record(env, user, Symbol::new(&env, "sneaker-drop")).unwrap(); + client.deposit(&user, &queue_id, &500i128, &asset); + client.release(&admin, &user, &queue_id); + let record = client.get_record(&user, &queue_id).unwrap(); assert!(matches!(record.status, EscrowStatus::Released)); } #[test] fn test_refund_changes_status() { - let (env, admin) = setup(); - EscrowImpl::set_config(env.clone(), admin.clone(), make_config(&env, &admin)); - let user = Address::new(&env, &[4u8; 7]); - let asset = Address::new(&env, &[8u8; 7]); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 500i128, - asset, - ); - EscrowImpl::refund( - env.clone(), - admin.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - ); + let (env, admin, queue_id, contract_id) = setup(); + let client = EscrowImplClient::new(&env, &contract_id); + client.set_config(&admin, &make_config(&env, &admin)); let user = Address::generate(&env); let asset = Address::generate(&env); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 500i128, - asset, - ); - EscrowImpl::refund( - env.clone(), - admin.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - ); - let record = EscrowImpl::get_record(env, user, Symbol::new(&env, "sneaker-drop")).unwrap(); + client.deposit(&user, &queue_id, &500i128, &asset); + client.refund(&admin, &user, &queue_id); + let record = client.get_record(&user, &queue_id).unwrap(); assert!(matches!(record.status, EscrowStatus::Refunded)); } #[test] #[should_panic(expected = "amount must be positive")] fn test_deposit_rejects_non_positive_amount() { - let (env, admin) = setup(); - EscrowImpl::set_config(env.clone(), admin.clone(), make_config(&env, &admin)); + let (env, admin, queue_id, contract_id) = setup(); + let client = EscrowImplClient::new(&env, &contract_id); + client.set_config(&admin, &make_config(&env, &admin)); let user = Address::generate(&env); let asset = Address::generate(&env); - EscrowImpl::deposit(env, user, Symbol::new(&env, "sneaker-drop"), 0i128, asset); + client.deposit(&user, &queue_id, &0i128, &asset); } #[test] #[should_panic(expected = "amount outside configured bounds")] fn test_deposit_rejects_above_max() { - let (env, admin) = setup(); - EscrowImpl::set_config(env.clone(), admin.clone(), make_config(&env, &admin)); + let (env, admin, queue_id, contract_id) = setup(); + let client = EscrowImplClient::new(&env, &contract_id); + client.set_config(&admin, &make_config(&env, &admin)); let user = Address::generate(&env); let asset = Address::generate(&env); - EscrowImpl::deposit(env.clone(), user, Symbol::new(&env, "sneaker-drop"), 5000i128, asset); + client.deposit(&user, &queue_id, &5000i128, &asset); } #[test] #[should_panic(expected = "existing escrow record")] fn test_deposit_rejects_duplicate_for_same_user_and_queue() { - let (env, admin) = setup(); - EscrowImpl::set_config(env.clone(), admin.clone(), make_config(&env, &admin)); - let user = Address::new(&env, &[7u8; 7]); - let asset = Address::new(&env, &[8u8; 7]); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 250i128, - asset.clone(), - ); + let (env, admin, queue_id, contract_id) = setup(); + let client = EscrowImplClient::new(&env, &contract_id); + client.set_config(&admin, &make_config(&env, &admin)); let user = Address::generate(&env); let asset = Address::generate(&env); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 250i128, - asset.clone(), - ); - EscrowImpl::deposit(env, user, Symbol::new(&env, "sneaker-drop"), 300i128, asset); + client.deposit(&user, &queue_id, &250i128, &asset); + client.deposit(&user, &queue_id, &300i128, &asset); } #[test] fn test_expire_updates_status() { - let (env, admin) = setup(); + let (env, admin, queue_id, contract_id) = setup(); + let client = EscrowImplClient::new(&env, &contract_id); let mut config = make_config(&env, &admin); config.hold_period_days = 0; // expires immediately - EscrowImpl::set_config(env.clone(), admin.clone(), config); - let user = Address::new(&env, &[10u8; 7]); - let asset = Address::new(&env, &[8u8; 7]); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 200i128, - asset, - ); + client.set_config(&admin, &config); let user = Address::generate(&env); let asset = Address::generate(&env); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 200i128, - asset, - ); - EscrowImpl::expire(env.clone(), user.clone(), Symbol::new(&env, "sneaker-drop")); - let record = EscrowImpl::get_record(env, user, Symbol::new(&env, "sneaker-drop")).unwrap(); + client.deposit(&user, &queue_id, &200i128, &asset); + client.expire(&user, &queue_id); + let record = client.get_record(&user, &queue_id).unwrap(); assert!(matches!(record.status, EscrowStatus::Expired)); } #[test] #[should_panic(expected = "escrow not active")] fn test_release_already_released_panics() { - let (env, admin) = setup(); - EscrowImpl::set_config(env.clone(), admin.clone(), make_config(&env, &admin)); - let user = Address::new(&env, &[11u8; 7]); - let asset = Address::new(&env, &[8u8; 7]); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 500i128, - asset, - ); - EscrowImpl::release( - env.clone(), - admin.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - ); + let (env, admin, queue_id, contract_id) = setup(); + let client = EscrowImplClient::new(&env, &contract_id); + client.set_config(&admin, &make_config(&env, &admin)); let user = Address::generate(&env); let asset = Address::generate(&env); - EscrowImpl::deposit( - env.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - 500i128, - asset, - ); - EscrowImpl::release( - env.clone(), - admin.clone(), - user.clone(), - Symbol::new(&env, "sneaker-drop"), - ); - EscrowImpl::release(env, admin, user, Symbol::new(&env, "sneaker-drop")); + client.deposit(&user, &queue_id, &500i128, &asset); + client.release(&admin, &user, &queue_id); + client.release(&admin, &user, &queue_id); } diff --git a/contracts/lineproof-identity/src/lib.rs b/contracts/lineproof-identity/src/lib.rs index 078f7a4..9359827 100644 --- a/contracts/lineproof-identity/src/lib.rs +++ b/contracts/lineproof-identity/src/lib.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(test), no_std)] + use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Vec}; /// TTL threshold: renew if remaining TTL is below this many ledgers (~13.8 hours at 5s/ledger) @@ -79,7 +81,7 @@ impl Identity for IdentityImpl { let mut record = Self::get_record_internal(&env, &identity); let mut updated: Vec = Vec::new(&env); for q in record.queues.iter() { - if q != &queue_id { + if q != queue_id { updated.push_back(q.clone()); } } @@ -100,7 +102,7 @@ impl Identity for IdentityImpl { fn is_bound(env: Env, identity: Address, queue_id: Symbol) -> bool { let record = Self::get_record_internal(&env, &identity); - record.queues.iter().any(|q| q == &queue_id) + record.queues.iter().any(|q| q == queue_id) } fn can_transfer(env: Env, from: Address, to: Address, queue_id: Symbol) -> bool { @@ -113,7 +115,7 @@ impl Identity for IdentityImpl { return false; } - let is_bound = record.queues.iter().any(|q| q == &queue_id); + let is_bound = record.queues.iter().any(|q| q == queue_id); if !is_bound { return false; } @@ -175,9 +177,6 @@ impl Identity for IdentityImpl { env.storage() .persistent() .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - env.storage() - .persistent() - .extend_ttl(&env.current_contract_address(), TTL_THRESHOLD, TTL_EXTEND_TO); } fn get_admin(env: Env) -> Option
{ @@ -206,7 +205,7 @@ impl Identity for IdentityImpl { emit( &env, Symbol::new(&env, "Revoked"), - Symbol::new(&env, ""), + Symbol::new(&env, "none"), &identity, env.ledger().timestamp(), ); @@ -216,16 +215,20 @@ impl Identity for IdentityImpl { impl IdentityImpl { fn get_record_internal(env: &Env, identity: &Address) -> IdentityRecord { let key = Self::record_key(env, identity); - let record = env.storage().persistent().get(&key).unwrap_or(IdentityRecord { - identity: identity.clone(), - bound_at: 0, - queues: Vec::new(env), - status: BindingStatus::Unbound, - }); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - record + if env.storage().persistent().has(&key) { + let record: IdentityRecord = env.storage().persistent().get(&key).unwrap(); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + record + } else { + IdentityRecord { + identity: identity.clone(), + bound_at: 0, + queues: Vec::new(env), + status: BindingStatus::Unbound, + } + } } pub fn record_key(env: &Env, identity: &Address) -> (Symbol, Address) { diff --git a/contracts/lineproof-identity/src/test.rs b/contracts/lineproof-identity/src/test.rs index 573f5d8..9c32502 100644 --- a/contracts/lineproof-identity/src/test.rs +++ b/contracts/lineproof-identity/src/test.rs @@ -1,146 +1,174 @@ -use crate::{BindingStatus, Identity, IdentityImpl}; -use crate::{BindingStatus, IdentityImpl}; -use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; -fn setup() -> (Env, Address) { - let env = Env::default(); - let user = Address::generate(&env); - (env, user) +use crate::{BindingStatus, Identity, IdentityImpl, IdentityImplClient, TransferAttempt}; + +struct Harness { + env: Env, + contract_id: Address, + admin: Address, +} + +impl Harness { + fn new() -> Self { + let env = Env::default(); + let contract_id = env.register(IdentityImpl, ()); + env.mock_all_auths(); + let admin = Address::generate(&env); + let client = IdentityImplClient::new(&env, &contract_id); + client.initialize(&admin); + Harness { + env, + contract_id, + admin, + } + } + + fn client(&self) -> IdentityImplClient<'_> { + IdentityImplClient::new(&self.env, &self.contract_id) + } } #[test] fn test_bind_creates_record_with_timestamp() { - let (env, user) = setup(); - let queue_id = Symbol::new(&env, "sneaker-drop"); - IdentityImpl::bind(env.clone(), user.clone(), queue_id.clone()); - let record = IdentityImpl::get_record(env.clone(), user.clone()).unwrap(); - assert!(record.queues.iter().any(|q| q == &queue_id)); + let h = Harness::new(); + let client = h.client(); + let user = Address::generate(&h.env); + let queue_id = Symbol::new(&h.env, "sneaker_drop"); + client.bind(&user, &queue_id); + let record = client.get_record(&user).unwrap(); + assert!(record.queues.iter().any(|q| &q == &queue_id)); assert!(matches!(record.status, BindingStatus::Bound)); - // bound_at should be set (ledger timestamp in tests defaults to 0) - assert_eq!(record.bound_at, 0); // default test env timestamp + assert_eq!(record.bound_at, 0); } #[test] fn test_unbind_removes_queue() { - let (env, user) = setup(); - let queue_id = Symbol::new(&env, "concert"); - IdentityImpl::bind(env.clone(), user.clone(), queue_id.clone()); - IdentityImpl::unbind(env.clone(), user.clone(), queue_id.clone()); - assert!(!IdentityImpl::is_bound(env.clone(), user.clone(), queue_id)); + let h = Harness::new(); + let client = h.client(); + let user = Address::generate(&h.env); + let queue_id = Symbol::new(&h.env, "concert"); + client.bind(&user, &queue_id); + client.unbind(&user, &queue_id); + assert!(!client.is_bound(&user, &queue_id)); } #[test] fn test_is_bound_returns_false_before_bind() { - let (env, user) = setup(); - let queue_id = Symbol::new(&env, "new-queue"); - assert!(!IdentityImpl::is_bound(env, user, queue_id)); + let h = Harness::new(); + let user = Address::generate(&h.env); + let queue_id = Symbol::new(&h.env, "new_queue"); + assert!(!h.client().is_bound(&user, &queue_id)); } #[test] fn test_can_transfer_returns_false_for_revoked_identity() { - let (env, admin) = setup(); - IdentityImpl::initialize(env.clone(), admin.clone()); - let user = Address::generate(&env); - let other = Address::generate(&env); - let queue_id = Symbol::new(&env, "q-transfer"); - IdentityImpl::bind(env.clone(), user.clone(), queue_id.clone()); - IdentityImpl::set_transfer_allowed(env.clone(), admin.clone(), true); - IdentityImpl::revoke(env.clone(), admin, user.clone()); - assert!(!IdentityImpl::can_transfer(env, user, other, queue_id)); + let h = Harness::new(); + let client = h.client(); + let user = Address::generate(&h.env); + let other = Address::generate(&h.env); + let queue_id = Symbol::new(&h.env, "q_transfer"); + client.bind(&user, &queue_id); + client.set_transfer_allowed(&h.admin, &true); + client.revoke(&h.admin, &user); + assert!(!client.can_transfer(&user, &other, &queue_id)); } #[test] fn test_can_transfer_returns_false_when_unbound() { - let (env, admin) = setup(); - IdentityImpl::initialize(env.clone(), admin.clone()); - let user = Address::generate(&env); - let other = Address::generate(&env); - let queue_id = Symbol::new(&env, "q-unbound"); - IdentityImpl::set_transfer_allowed(env.clone(), admin.clone(), true); - assert!(!IdentityImpl::can_transfer(env, user, other, queue_id)); + let h = Harness::new(); + let client = h.client(); + let user = Address::generate(&h.env); + let other = Address::generate(&h.env); + let queue_id = Symbol::new(&h.env, "q_unbound"); + client.set_transfer_allowed(&h.admin, &true); + assert!(!client.can_transfer(&user, &other, &queue_id)); } #[test] fn test_can_transfer_returns_true_when_allowed_and_bound() { - let (env, admin) = setup(); - IdentityImpl::initialize(env.clone(), admin.clone()); - let user = Address::generate(&env); - let other = Address::generate(&env); - let queue_id = Symbol::new(&env, "q-allowed"); - IdentityImpl::bind(env.clone(), user.clone(), queue_id.clone()); - IdentityImpl::set_transfer_allowed(env.clone(), admin.clone(), true); - assert!(IdentityImpl::can_transfer(env, user, other, queue_id)); + let h = Harness::new(); + let client = h.client(); + let user = Address::generate(&h.env); + let other = Address::generate(&h.env); + let queue_id = Symbol::new(&h.env, "q_allowed"); + client.bind(&user, &queue_id); + client.set_transfer_allowed(&h.admin, &true); + assert!(client.can_transfer(&user, &other, &queue_id)); } #[test] fn test_can_transfer_returns_false_when_not_allowed_but_bound() { - let (env, admin) = setup(); - IdentityImpl::initialize(env.clone(), admin.clone()); - let user = Address::generate(&env); - let other = Address::generate(&env); - let queue_id = Symbol::new(&env, "q-not-allowed"); - IdentityImpl::bind(env.clone(), user.clone(), queue_id.clone()); - // Not setting transfer_allowed to true (default is false) - assert!(!IdentityImpl::can_transfer(env, user, other, queue_id)); + let h = Harness::new(); + let client = h.client(); + let user = Address::generate(&h.env); + let other = Address::generate(&h.env); + let queue_id = Symbol::new(&h.env, "q_not_allowed"); + client.bind(&user, &queue_id); + assert!(!client.can_transfer(&user, &other, &queue_id)); } #[test] fn test_can_transfer_returns_true_same_identity() { - let (env, user) = setup(); - let queue_id = Symbol::new(&env, "self"); - assert!(IdentityImpl::can_transfer(env, user.clone(), user, queue_id)); + let h = Harness::new(); + let client = h.client(); + let user = Address::generate(&h.env); + let queue_id = Symbol::new(&h.env, "self"); + assert!(client.can_transfer(&user, &user, &queue_id)); } #[test] fn test_record_transfer_attempt_persists() { - let (env, user) = setup(); - let other = Address::generate(&env); - let queue_id = Symbol::new(&env, "drop"); - IdentityImpl::record_transfer_attempt(env.clone(), user.clone(), other.clone(), queue_id.clone()); - let key = IdentityImpl::attempt_key(&env, &user, &other, &queue_id); - let attempt = env.storage().persistent().get::<_, crate::TransferAttempt>(&key); + let h = Harness::new(); + let client = h.client(); + let user = Address::generate(&h.env); + let other = Address::generate(&h.env); + let queue_id = Symbol::new(&h.env, "drop"); + client.record_transfer_attempt(&user, &other, &queue_id); + let key = IdentityImpl::attempt_key(&h.env, &user, &other, &queue_id); + let attempt = h.env.as_contract(&h.contract_id, || { + h.env.storage().persistent().get::<_, TransferAttempt>(&key) + }); assert!(attempt.is_some()); assert!(attempt.unwrap().reverted); } #[test] fn test_initialize_sets_admin() { - let (env, admin) = setup(); - IdentityImpl::initialize(env.clone(), admin.clone()); - let stored = IdentityImpl::get_admin(env.clone()); - assert_eq!(stored, Some(admin)); + let h = Harness::new(); + let client = h.client(); + let stored = client.get_admin(); + assert_eq!(stored, Some(h.admin)); } #[test] #[should_panic(expected = "already initialized")] fn test_initialize_twice_panics() { - let (env, admin) = setup(); - IdentityImpl::initialize(env.clone(), admin.clone()); - IdentityImpl::initialize(env, admin); + let h = Harness::new(); + let client = h.client(); + client.initialize(&h.admin); } #[test] fn test_revoke_sets_revoked_status() { - let (env, admin) = setup(); - IdentityImpl::initialize(env.clone(), admin.clone()); - let user = Address::generate(&env); - let queue_id = Symbol::new(&env, "q"); - IdentityImpl::bind(env.clone(), user.clone(), queue_id); - IdentityImpl::revoke(env.clone(), admin.clone(), user.clone()); - let record = IdentityImpl::get_record(env, user).unwrap(); + let h = Harness::new(); + let client = h.client(); + let user = Address::generate(&h.env); + let queue_id = Symbol::new(&h.env, "q"); + client.bind(&user, &queue_id); + client.revoke(&h.admin, &user); + let record = client.get_record(&user).unwrap(); assert!(matches!(record.status, BindingStatus::Revoked)); } #[test] #[should_panic(expected = "identity revoked")] fn test_bind_after_revoke_panics() { - let (env, admin) = setup(); - IdentityImpl::initialize(env.clone(), admin.clone()); - let user = Address::generate(&env); - let q1 = Symbol::new(&env, "q1"); - let q2 = Symbol::new(&env, "q2"); - IdentityImpl::bind(env.clone(), user.clone(), q1); - IdentityImpl::revoke(env.clone(), admin, user.clone()); - IdentityImpl::bind(env, user, q2); + let h = Harness::new(); + let client = h.client(); + let user = Address::generate(&h.env); + let q1 = Symbol::new(&h.env, "q1"); + let q2 = Symbol::new(&h.env, "q2"); + client.bind(&user, &q1); + client.revoke(&h.admin, &user); + client.bind(&user, &q2); } diff --git a/contracts/lineproof-queue-factory/src/lib.rs b/contracts/lineproof-queue-factory/src/lib.rs index 2e6f8e3..cc4d621 100644 --- a/contracts/lineproof-queue-factory/src/lib.rs +++ b/contracts/lineproof-queue-factory/src/lib.rs @@ -1,4 +1,6 @@ -use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, Symbol, Vec}; +#![cfg_attr(not(test), no_std)] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, IntoVal, Symbol, Vec}; /// Storage key prefix for queue registry const QUEUE_REGISTRY_PREFIX: &str = "queue"; @@ -20,7 +22,7 @@ pub struct QueueMetadata { pub slug: Symbol, pub name: Symbol, pub owner: Address, - pub contract_id: BytesN<32>, + pub contract_address: Address, pub version: u32, pub deployed_at: u64, pub active: bool, @@ -43,8 +45,9 @@ pub trait QueueFactory { name: Symbol, version: u32, wasm_hash: BytesN<32>, - ) -> BytesN<32>; - fn register_queue(env: Env, admin: Address, slug: Symbol, contract_id: BytesN<32>, version: u32); + salt: BytesN<32>, + ) -> Address; + fn register_queue(env: Env, admin: Address, slug: Symbol, contract_address: Address, version: u32); fn register_approved_hash(env: Env, admin: Address, version: u32, wasm_hash: BytesN<32>); fn deactivate_queue(env: Env, admin: Address, slug: Symbol); fn reactivate_queue(env: Env, admin: Address, slug: Symbol); @@ -84,14 +87,11 @@ impl QueueFactory for QueueFactoryImpl { env.storage() .persistent() .extend_ttl(&idx_key, TTL_THRESHOLD, TTL_EXTEND_TO); - env.storage() - .persistent() - .extend_ttl(&env.current_contract_address(), TTL_THRESHOLD, TTL_EXTEND_TO); emit( &env, Symbol::new(&env, "Init"), - Symbol::new(&env, ""), - BytesN::new(&env, &[0u8; 32]), + Symbol::new(&env, "none"), + Self::zero_contract_address(&env), 0, 0, ); @@ -104,7 +104,8 @@ impl QueueFactory for QueueFactoryImpl { name: Symbol, version: u32, wasm_hash: BytesN<32>, - ) -> BytesN<32> { + salt: BytesN<32>, + ) -> Address { deployer.require_auth(); let config_key = Symbol::new(&env, "config"); let config: FactoryConfig = env.storage().persistent().get(&config_key).unwrap(); @@ -117,13 +118,20 @@ impl QueueFactory for QueueFactoryImpl { if env.storage().persistent().has(®istry_key) { panic!("queue with this slug already exists"); } - let contract_id = env.deployer().with_current_contract(&wasm_hash).deploy(); + // Deploy the queue from the current (factory) contract, using the caller + // supplied salt so that the resulting contract address is deterministic + // but NOT predictable in advance by a third party who lacks the salt. + // Two deployments with different salts yield different contract addresses. + let contract_address = env + .deployer() + .with_current_contract(salt.clone()) + .deploy_v2(wasm_hash.clone(), ()); let deployed_at = env.ledger().timestamp(); let metadata = QueueMetadata { slug: slug.clone(), name, owner: deployer, - contract_id: contract_id.clone(), + contract_address: contract_address.clone(), version, deployed_at, active: true, @@ -137,14 +145,38 @@ impl QueueFactory for QueueFactoryImpl { &env, Symbol::new(&env, "Deployed"), slug, - contract_id.clone(), + contract_address.clone(), version, deployed_at, ); - contract_id + contract_address } - fn register_queue(env: Env, admin: Address, slug: Symbol, contract_id: BytesN<32>, version: u32) { + fn register_approved_hash(env: Env, admin: Address, version: u32, wasm_hash: BytesN<32>) { + Self::require_admin(&env, &admin); + let key = Self::approved_hash_key(&env, version); + env.storage().persistent().set(&key, &wasm_hash); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + // Enable the approved hash registry once the first hash is registered, + // so subsequent deployments/upgrades are validated against it. + let enabled_key = Symbol::new(&env, APPROVED_REGISTRY_ENABLED_KEY); + env.storage().persistent().set(&enabled_key, &true); + env.storage() + .persistent() + .extend_ttl(&enabled_key, TTL_THRESHOLD, TTL_EXTEND_TO); + emit( + &env, + Symbol::new(&env, "HashApproved"), + Symbol::new(&env, "none"), + Self::zero_contract_address(&env), + version, + env.ledger().timestamp(), + ); + } + + fn register_queue(env: Env, admin: Address, slug: Symbol, contract_address: Address, version: u32) { Self::require_admin(&env, &admin); let registry_key = Self::queue_registry_key(&env, &slug); if env.storage().persistent().has(®istry_key) { @@ -153,9 +185,9 @@ impl QueueFactory for QueueFactoryImpl { let deployed_at = env.ledger().timestamp(); let metadata = QueueMetadata { slug: slug.clone(), - name: Symbol::new(&env, "(imported)"), + name: Symbol::new(&env, "imported"), owner: admin.clone(), - contract_id: contract_id.clone(), + contract_address: contract_address.clone(), version, deployed_at, active: true, @@ -169,7 +201,7 @@ impl QueueFactory for QueueFactoryImpl { &env, Symbol::new(&env, "Registered"), slug, - contract_id, + contract_address, version, deployed_at, ); @@ -188,7 +220,7 @@ impl QueueFactory for QueueFactoryImpl { &env, Symbol::new(&env, "Deactivated"), slug, - metadata.contract_id, + metadata.contract_address, metadata.version, env.ledger().timestamp(), ); @@ -207,7 +239,7 @@ impl QueueFactory for QueueFactoryImpl { &env, Symbol::new(&env, "Reactivated"), slug, - metadata.contract_id, + metadata.contract_address, metadata.version, env.ledger().timestamp(), ); @@ -230,9 +262,7 @@ impl QueueFactory for QueueFactoryImpl { &env, Symbol::new(&env, "Destroyed"), slug, - BytesN::new(&env, &[0u8; 32]), - 0, - metadata.contract_id, + metadata.contract_address, metadata.version, env.ledger().timestamp(), ); @@ -287,23 +317,29 @@ impl QueueFactory for QueueFactoryImpl { } Self::validate_approved_hash(&env, new_version, &new_wasm_hash); Self::require_approved_hash(&env, new_version, &new_wasm_hash); - let contract_id = metadata.contract_id.clone(); + let contract_address = metadata.contract_address.clone(); metadata.version = new_version; let registry_key = Self::queue_registry_key(&env, &slug); env.storage().persistent().set(®istry_key, &metadata); env.storage() .persistent() .extend_ttl(®istry_key, TTL_THRESHOLD, TTL_EXTEND_TO); - // Upgrade the WASM code. The queue contract should call migrate() afterward - // if storage transformations are needed for the new version. - env.deployer() - .with_current_contract(&new_wasm_hash) - .upgrade(&contract_id); + // NOTE: Soroban SDK 22 removed the factory-facing `upgrade` API used to + // replace another contract's WASM directly. A contract can only update + // its OWN executable via `env.deployer().update_current_contract_wasm`. + // The factory therefore records the new version here and delegates the + // actual WASM swap to the queue's own `upgrade` entry point, which + // authorizes the admin and self-updates its executable. + env.invoke_contract::<()>( + &contract_address, + &Symbol::new(&env, "upgrade"), + (admin, new_wasm_hash).into_val(&env), + ); emit( &env, Symbol::new(&env, "Upgraded"), slug, - contract_id, + contract_address, new_version, env.ledger().timestamp(), ); @@ -379,6 +415,13 @@ impl QueueFactoryImpl { metadata } + /// The canonical "no contract" address (the zero 32-byte contract id). Used + /// as a placeholder in events that are not tied to a specific queue (e.g. + /// `Init`, `HashApproved`). + fn zero_contract_address(env: &Env) -> Address { + env.current_contract_address() + } + fn append_slug(env: &Env, slug: &Symbol) { let idx_key = Symbol::new(env, SLUG_INDEX_KEY); let mut slugs: Vec = env.storage().persistent().get(&idx_key).unwrap_or(Vec::new(env)); @@ -410,11 +453,11 @@ impl QueueFactoryImpl { } } -fn emit(env: &Env, kind: Symbol, slug: Symbol, contract_id: BytesN<32>, version: u32, _timestamp: u64) { - // #83: carry the deployed contract id and version in the event payload. +fn emit(env: &Env, kind: Symbol, slug: Symbol, contract_address: Address, version: u32, _timestamp: u64) { + // #83: carry the deployed contract address and version in the event payload. env.events().publish( (Symbol::new(env, "lineproof_factory"), kind, slug), - (contract_id.clone(), version), + (contract_address.clone(), version), ); } diff --git a/contracts/lineproof-queue-factory/src/test.rs b/contracts/lineproof-queue-factory/src/test.rs index 002376e..aee83ea 100644 --- a/contracts/lineproof-queue-factory/src/test.rs +++ b/contracts/lineproof-queue-factory/src/test.rs @@ -1,24 +1,64 @@ -use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, Symbol}; +use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env, Symbol}; -use crate::{FactoryConfig, QueueFactoryImpl}; +use crate::{FactoryConfig, QueueFactoryImpl, QueueFactoryImplClient}; -fn setup() -> (Env, Address) { - let env = Env::default(); - let admin = Address::new(&env, &[1; 7]); - (env, admin) +/// Self-contained fixture of the deployed queue WASM so the deploy tests do not +/// depend on a prior workspace wasm build. `upload_contract_wasm` derives a +/// deterministic hash from these bytes, so identical bytes always yield the +/// same hash across tests. +const QUEUE_WASM: &[u8] = include_bytes!("../test-fixtures/queue.wasm"); + +struct Harness { + env: Env, + contract_id: Address, + admin: Address, } -fn init(env: &Env, admin: &Address) { - QueueFactoryImpl::initialize(env.clone(), admin.clone()); +impl Harness { + fn new() -> Self { + let env = Env::default(); + let contract_id = env.register(QueueFactoryImpl, ()); + env.mock_all_auths(); + let client = QueueFactoryImplClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); + Harness { + env, + contract_id, + admin, + } + } + + fn client(&self) -> QueueFactoryImplClient<'_> { + QueueFactoryImplClient::new(&self.env, &self.contract_id) + } + + fn wasm_hash(&self) -> BytesN<32> { + let wasm = Bytes::from_slice(&self.env, QUEUE_WASM); + self.env.deployer().upload_contract_wasm(wasm) + } + + fn deploy(&self, deployer: &Address, slug: Symbol, version: u32, salt: BytesN<32>) -> Address { + let client = self.client(); + client.deploy_queue( + deployer, + &slug, + &Symbol::new(&self.env, "T"), + &version, + &self.wasm_hash(), + &salt, + ) + } } #[test] fn test_initialize() { - let (env, admin) = setup(); - init(&env, &admin); - let key = Symbol::new(&env, "config"); - let config: FactoryConfig = env.storage().persistent().get(&key).unwrap(); - assert_eq!(config.admin, admin); + let h = Harness::new(); + let key = Symbol::new(&h.env, "config"); + let config: FactoryConfig = h + .env + .as_contract(&h.contract_id, || h.env.storage().persistent().get(&key).unwrap()); + assert_eq!(config.admin, h.admin); assert_eq!(config.min_version, 1); assert_eq!(config.max_version, 1); } @@ -26,238 +66,203 @@ fn test_initialize() { #[test] #[should_panic(expected = "already initialized")] fn test_initialize_twice_panics() { - let (env, admin) = setup(); - init(&env, &admin); - init(&env, &admin); + let h = Harness::new(); + let client = h.client(); + client.initialize(&h.admin); } #[test] fn test_deploy_queue_registers_and_indexes() { - let (env, admin) = setup(); - init(&env, &admin); - let deployer = Address::new(&env, &[2u8; 7]); - let slug = Symbol::new(&env, "test-q"); - let wasm_hash = soroban_sdk::BytesN::new(&env, &[3u8; 32]); - QueueFactoryImpl::deploy_queue( - env.clone(), - deployer, - slug.clone(), - Symbol::new(&env, "T"), - 1, - wasm_hash, + let h = Harness::new(); + let client = h.client(); + let deployer = Address::generate(&h.env); + let slug = Symbol::new(&h.env, "test_q"); + let deployed = h.deploy(&deployer, slug.clone(), 1, BytesN::from_array(&h.env, &[3u8; 32])); + + let meta = client.get_queue(&slug).unwrap(); + assert!(meta.active); + assert_eq!(meta.owner, deployer); + assert_eq!(meta.contract_address, deployed); + assert_eq!(meta.slug, slug); + assert_eq!(meta.version, 1); + + assert_eq!(client.list_queues().len(), 1); + assert_eq!(client.queue_count(), 1); +} + +#[test] +#[should_panic(expected = "version out of bounds")] +fn test_deploy_rejects_bad_version() { + let h = Harness::new(); + let deployer = Address::generate(&h.env); + h.deploy( + &deployer, + Symbol::new(&h.env, "x"), + 99, + BytesN::from_array(&h.env, &[3u8; 32]), ); +} - let meta = QueueFactoryImpl::get_queue(env.clone(), slug.clone()); - assert!(meta.is_some()); - assert!(meta.unwrap().active); +#[test] +#[should_panic(expected = "queue with this slug already exists")] +fn test_deploy_rejects_duplicate_slug() { + let h = Harness::new(); + let deployer = Address::generate(&h.env); + let slug = Symbol::new(&h.env, "dup"); + h.deploy(&deployer, slug.clone(), 1, BytesN::from_array(&h.env, &[3u8; 32])); + h.deploy(&deployer, slug, 1, BytesN::from_array(&h.env, &[4u8; 32])); +} + +#[test] +fn test_same_wasm_different_salt_yields_different_addresses() { + // Acceptance criterion for #206: deploying the same queue WASM from the + // same factory with a different salt must produce distinct contract + // addresses. A caller-supplied salt makes the address deterministic for a + // known salt but not front-runnable by a third party who lacks it. + let h = Harness::new(); + let client = h.client(); + let deployer = Address::generate(&h.env); + let salt_a = BytesN::from_array(&h.env, &[1u8; 32]); + let salt_b = BytesN::from_array(&h.env, &[2u8; 32]); - let slugs = QueueFactoryImpl::list_queues(env.clone()); - assert_eq!(slugs.len(), 1); + let addr_a = h.deploy(&deployer, Symbol::new(&h.env, "qa"), 1, salt_a); + let addr_b = h.deploy(&deployer, Symbol::new(&h.env, "qb"), 1, salt_b); - let count = QueueFactoryImpl::queue_count(env.clone()); - assert_eq!(count, 1); + assert_ne!(addr_a, addr_b); + + let meta_a = client.get_queue(&Symbol::new(&h.env, "qa")).unwrap(); + let meta_b = client.get_queue(&Symbol::new(&h.env, "qb")).unwrap(); + assert_ne!(meta_a.contract_address, meta_b.contract_address); + assert_eq!(meta_a.contract_address, addr_a); + assert_eq!(meta_b.contract_address, addr_b); } #[test] fn test_list_queues_returns_all_slugs() { - let (env, admin) = setup(); - init(&env, &admin); - let deployer = Address::new(&env, &[2u8; 7]); + let h = Harness::new(); + let client = h.client(); + let deployer = Address::generate(&h.env); for i in 0u8..3 { - let slug = Symbol::new(&env, &format!("q{}", i)); - let wasm_hash = soroban_sdk::BytesN::new(&env, &[i + 10; 32]); - QueueFactoryImpl::deploy_queue( - env.clone(), - deployer.clone(), - slug, - Symbol::new(&env, "N"), + h.deploy( + &deployer, + Symbol::new(&h.env, &format!("q{}", i)), 1, - wasm_hash, + BytesN::from_array(&h.env, &[10 + i; 32]), ); } - let slugs = QueueFactoryImpl::list_queues(env.clone()); - assert_eq!(slugs.len(), 3); - assert_eq!(QueueFactoryImpl::queue_count(env), 3); + assert_eq!(client.list_queues().len(), 3); + assert_eq!(client.queue_count(), 3); } #[test] fn test_deactivate_and_reactivate() { - let (env, admin) = setup(); - init(&env, &admin); - let deployer = Address::new(&env, &[2u8; 7]); - let slug = Symbol::new(&env, "toggle"); - let wasm_hash = soroban_sdk::BytesN::new(&env, &[7u8; 32]); - QueueFactoryImpl::deploy_queue( - env.clone(), - deployer, - slug.clone(), - Symbol::new(&env, "T"), - 1, - wasm_hash, - ); - assert!(QueueFactoryImpl::verify_queue(env.clone(), slug.clone())); - QueueFactoryImpl::deactivate_queue(env.clone(), admin.clone(), slug.clone()); - assert!(!QueueFactoryImpl::verify_queue(env.clone(), slug.clone())); - QueueFactoryImpl::reactivate_queue(env.clone(), admin, slug.clone()); - assert!(QueueFactoryImpl::verify_queue(env.clone(), slug)); + let h = Harness::new(); + let client = h.client(); + let deployer = Address::generate(&h.env); + let slug = Symbol::new(&h.env, "toggle"); + h.deploy(&deployer, slug.clone(), 1, BytesN::from_array(&h.env, &[7u8; 32])); + assert!(client.verify_queue(&slug)); + client.deactivate_queue(&h.admin, &slug); + assert!(!client.verify_queue(&slug)); + client.reactivate_queue(&h.admin, &slug); + assert!(client.verify_queue(&slug)); } #[test] fn test_get_queue_returns_none_for_unknown() { - let (env, admin) = setup(); - init(&env, &admin); - let result = QueueFactoryImpl::get_queue(env, Symbol::new(&env, "ghost")); - assert!(result.is_none()); -} - -#[test] -#[should_panic(expected = "version out of bounds")] -fn test_deploy_rejects_bad_version() { - let (env, admin) = setup(); - init(&env, &admin); - let deployer = Address::new(&env, &[2u8; 7]); - let wasm_hash = soroban_sdk::BytesN::new(&env, &[3u8; 32]); - QueueFactoryImpl::deploy_queue( - env, - deployer, - Symbol::new(&env, "x"), - Symbol::new(&env, "X"), - 99, - wasm_hash, - ); -} - -#[test] -#[should_panic(expected = "queue with this slug already exists")] -fn test_deploy_rejects_duplicate_slug() { - let (env, admin) = setup(); - init(&env, &admin); - let deployer = Address::new(&env, &[2u8; 7]); - let slug = Symbol::new(&env, "dup"); - QueueFactoryImpl::deploy_queue( - env.clone(), - deployer.clone(), - slug.clone(), - Symbol::new(&env, "D"), - 1, - soroban_sdk::BytesN::new(&env, &[3u8; 32]), - ); - QueueFactoryImpl::deploy_queue( - env, - deployer, - slug, - Symbol::new(&env, "D"), - 1, - soroban_sdk::BytesN::new(&env, &[4u8; 32]), - ); + let h = Harness::new(); + let client = h.client(); + assert!(client.get_queue(&Symbol::new(&h.env, "ghost")).is_none()); } #[test] #[should_panic(expected = "version must increase")] fn test_upgrade_rejects_downgrade() { - let (env, admin) = setup(); - init(&env, &admin); - QueueFactoryImpl::set_config(env.clone(), admin.clone(), 1, 3); - let slug = Symbol::new(&env, "secure"); - let slug = Symbol::new(&env, "downgrade"); - QueueFactoryImpl::register_queue( - env.clone(), - admin.clone(), - slug.clone(), - BytesN::new(&env, &[4u8; 32]), - 2, - ); + let h = Harness::new(); + let client = h.client(); + client.set_config(&h.admin, &1, &3); + let slug = Symbol::new(&h.env, "downgrade"); + let addr = Address::generate(&h.env); + client.register_queue(&h.admin, &slug, &addr, &2); - QueueFactoryImpl::upgrade_queue(env.clone(), admin, slug, 1, BytesN::new(&env, &[5u8; 32])); + client.upgrade_queue(&h.admin, &slug, &1, &BytesN::from_array(&h.env, &[5u8; 32])); } #[test] #[should_panic(expected = "wasm hash not approved")] fn test_upgrade_rejects_unapproved_hash() { - let (env, admin) = setup(); - init(&env, &admin); - QueueFactoryImpl::set_config(env.clone(), admin.clone(), 1, 2); - QueueFactoryImpl::register_approved_hash(env.clone(), admin.clone(), 2, BytesN::new(&env, &[6u8; 32])); - let slug = Symbol::new(&env, "secure"); - QueueFactoryImpl::register_queue( - env.clone(), - admin.clone(), - slug.clone(), - BytesN::new(&env, &[4u8; 32]), - 1, - ); + let h = Harness::new(); + let client = h.client(); + client.set_config(&h.admin, &1, &3); + client.register_approved_hash(&h.admin, &3, &BytesN::from_array(&h.env, &[6u8; 32])); + let slug = Symbol::new(&h.env, "secure"); + let addr = Address::generate(&h.env); + client.register_queue(&h.admin, &slug, &addr, &2); - QueueFactoryImpl::upgrade_queue(env.clone(), admin, slug, 2, BytesN::new(&env, &[7u8; 32])); + client.upgrade_queue(&h.admin, &slug, &3, &BytesN::from_array(&h.env, &[7u8; 32])); } #[test] #[should_panic(expected = "wasm hash not approved")] fn test_deploy_rejects_unapproved_hash() { - let (env, admin) = setup(); - init(&env, &admin); - QueueFactoryImpl::register_approved_hash(env.clone(), admin, 1, BytesN::new(&env, &[6u8; 32])); - - QueueFactoryImpl::deploy_queue( - env.clone(), - Address::new(&env, &[2u8; 7]), - Symbol::new(&env, "secure"), - Symbol::new(&env, "S"), - 1, - BytesN::new(&env, &[7u8; 32]), + let h = Harness::new(); + let client = h.client(); + // Register a version-1 hash different from the deployed fixture so the + // approved-hash registry is enabled and then rejects the mismatched deploy. + client.register_approved_hash(&h.admin, &1, &BytesN::from_array(&h.env, &[6u8; 32])); + + let deployer = Address::generate(&h.env); + client.deploy_queue( + &deployer, + &Symbol::new(&h.env, "secure"), + &Symbol::new(&h.env, "S"), + &1, + &h.wasm_hash(), + &BytesN::from_array(&h.env, &[9u8; 32]), ); } #[test] -#[should_panic(expected = "WASM hash not approved")] -fn test_upgrade_rejects_unapproved_hash() { - let (env, admin) = setup(); - init(&env, &admin); - QueueFactoryImpl::set_config(env.clone(), admin.clone(), 1, 2); - QueueFactoryImpl::register_approved_hash( - env.clone(), - admin.clone(), - 2, - soroban_sdk::BytesN::new(&env, &[7u8; 32]), - ); - let slug = Symbol::new(&env, "unapproved"); - QueueFactoryImpl::register_queue( - env.clone(), - admin.clone(), - slug.clone(), - soroban_sdk::BytesN::new(&env, &[5u8; 32]), - 1, - ); - QueueFactoryImpl::upgrade_queue(env.clone(), admin, slug, 2, soroban_sdk::BytesN::new(&env, &[8u8; 32])); +fn test_register_queue_imports_existing_contract() { + let h = Harness::new(); + let client = h.client(); + let slug = Symbol::new(&h.env, "imported"); + let addr = Address::generate(&h.env); + client.register_queue(&h.admin, &slug, &addr, &3); + + let meta = client.get_queue(&slug).unwrap(); + assert_eq!(meta.contract_address, addr); + assert_eq!(meta.version, 3); + assert_eq!(client.queue_count(), 1); + assert_eq!(client.list_queues().len(), 1); } #[test] -fn test_destroy_removes_queue_and_allows_slug_reuse() { - let (env, admin) = setup(); - init(&env, &admin); - let slug = Symbol::new(&env, "reusable"); - QueueFactoryImpl::register_queue( - env.clone(), - admin.clone(), - slug.clone(), - BytesN::new(&env, &[8u8; 32]), - soroban_sdk::BytesN::new(&env, &[5u8; 32]), - 1, - ); +#[should_panic(expected = "queue already registered")] +fn test_register_queue_rejects_duplicate() { + let h = Harness::new(); + let client = h.client(); + let slug = Symbol::new(&h.env, "dup_import"); + let addr = Address::generate(&h.env); + client.register_queue(&h.admin, &slug, &addr, &1); + client.register_queue(&h.admin, &slug, &addr, &2); +} - QueueFactoryImpl::destroy_queue(env.clone(), admin.clone(), slug.clone()); - assert!(QueueFactoryImpl::get_queue(env.clone(), slug.clone()).is_none()); - assert_eq!(QueueFactoryImpl::queue_count(env.clone()), 0); +#[test] +fn test_destroy_removes_queue_and_allows_slug_reuse() { + let h = Harness::new(); + let client = h.client(); + let slug = Symbol::new(&h.env, "reusable"); + let addr = Address::generate(&h.env); - QueueFactoryImpl::register_queue(env.clone(), admin, slug.clone(), BytesN::new(&env, &[9u8; 32]), 1); - assert_eq!(QueueFactoryImpl::list_queues(env.clone()).len(), 0); + client.register_queue(&h.admin, &slug, &addr, &1); + client.destroy_queue(&h.admin, &slug); + assert!(client.get_queue(&slug).is_none()); + assert_eq!(client.queue_count(), 0); - QueueFactoryImpl::register_queue( - env.clone(), - admin, - slug.clone(), - soroban_sdk::BytesN::new(&env, &[6u8; 32]), - 1, - ); - assert!(QueueFactoryImpl::get_queue(env.clone(), slug).is_some()); - assert_eq!(QueueFactoryImpl::queue_count(env), 1); + let addr2 = Address::generate(&h.env); + client.register_queue(&h.admin, &slug, &addr2, &1); + assert_eq!(client.list_queues().len(), 1); + assert!(client.get_queue(&slug).is_some()); + assert_eq!(client.queue_count(), 1); } diff --git a/contracts/lineproof-queue-factory/test-fixtures/queue.wasm b/contracts/lineproof-queue-factory/test-fixtures/queue.wasm new file mode 100755 index 0000000..305080f Binary files /dev/null and b/contracts/lineproof-queue-factory/test-fixtures/queue.wasm differ diff --git a/contracts/lineproof-queue/src/lib.rs b/contracts/lineproof-queue/src/lib.rs index c262bef..77636d2 100644 --- a/contracts/lineproof-queue/src/lib.rs +++ b/contracts/lineproof-queue/src/lib.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(test), no_std)] + use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, Symbol, Vec}; /// TTL threshold: renew if remaining TTL is below this many ledgers (~13.8 hours at 5s/ledger) @@ -481,12 +483,6 @@ impl QueueImpl { // Apply version-specific migrations // This is a skeleton that can be extended with actual storage transformations // as the contract evolves in future versions. - match (from_version, to_version) { - // Example: (1, 2) would handle v1 -> v2 migration - _ => { - // No migration logic needed for current version transition - } - } // Update stored version env.storage().persistent().set(&version_key, &to_version); env.storage() diff --git a/docs/runbook-deployment.md b/docs/runbook-deployment.md index efc667c..bf37e1e 100644 --- a/docs/runbook-deployment.md +++ b/docs/runbook-deployment.md @@ -129,7 +129,56 @@ Expected output includes `admin`, `min_version`, and `max_version` fields. --- -## 7. Mainnet Checklist +## 7. Deploy a Queue via the Factory (with salt) + +Queue contracts are created by invoking `deploy_queue` on the factory. The +factory deploys a fresh `lineproof_queue` instance on your behalf using +Soroban's deterministic address derivation: + +``` +queue address = address(factory) + wasm_hash(queue.wasm) + salt +``` + +Because the address depends on the **salt**, two calls that use the same +factory and WASM but different salts produce **different** queue addresses. +This makes every deployment front-running safe: a caller cannot observe your +submission and force you onto the same address (the historical default-salt +behaviour, see [#206]). + +```bash +# Generate a random 32-byte salt (hex, 64 chars) +SALT=$(openssl rand -hex 32) + +soroban contract invoke \ + --id $FACTORY_CONTRACT_ID \ + --source deployer \ + --network testnet \ + -- deploy_queue \ + --deployer $(soroban keys address deployer) \ + --slug sneaker-drop \ + --name "Sneaker Drop" \ + --version 1 \ + --wasm_hash $QUEUE_WASM_HASH \ + --salt $SALT +``` + +Notes: + +- `--wasm_hash` must be a **pre-installed** WASM hash. Upload it with + `soroban contract install` first if it is not already on-chain. +- `--salt` accepts any 32-byte hex value. For repeatable/migratable queues you + may pin a fixed salt; for unique per-drop queues generate one randomly. +- The returned value is the new queue's `Address`, recorded as the queue's + identifier by downstream tooling. +- When driving the factory through the LineProof SDK, the + `QueueDeploymentParams.salt` field is optional — omitting it makes the SDK + generate a random salt automatically. + +[#206]: https://github.com/Stellar-Deejah/-LineProof/issues/206 + +--- + +## 8. Mainnet Checklist Before deploying to mainnet: diff --git a/examples/concert-ticket/package.json b/examples/concert-ticket/package.json index 26b6175..894cc9c 100644 --- a/examples/concert-ticket/package.json +++ b/examples/concert-ticket/package.json @@ -1,3 +1,4 @@ +{ "scripts": { "start": "tsx src/index.ts", "typecheck": "tsc --noEmit", @@ -12,3 +13,4 @@ "tsx": "^4.7.0", "typescript": "^5.5.0" } +} diff --git a/examples/event-ticketing/package.json b/examples/event-ticketing/package.json index 26b6175..894cc9c 100644 --- a/examples/event-ticketing/package.json +++ b/examples/event-ticketing/package.json @@ -1,3 +1,4 @@ +{ "scripts": { "start": "tsx src/index.ts", "typecheck": "tsc --noEmit", @@ -12,3 +13,4 @@ "tsx": "^4.7.0", "typescript": "^5.5.0" } +} diff --git a/examples/healthcare-scheduling/package.json b/examples/healthcare-scheduling/package.json index 26b6175..894cc9c 100644 --- a/examples/healthcare-scheduling/package.json +++ b/examples/healthcare-scheduling/package.json @@ -1,3 +1,4 @@ +{ "scripts": { "start": "tsx src/index.ts", "typecheck": "tsc --noEmit", @@ -12,3 +13,4 @@ "tsx": "^4.7.0", "typescript": "^5.5.0" } +} diff --git a/examples/sneaker-drop/package.json b/examples/sneaker-drop/package.json index 26b6175..894cc9c 100644 --- a/examples/sneaker-drop/package.json +++ b/examples/sneaker-drop/package.json @@ -1,3 +1,4 @@ +{ "scripts": { "start": "tsx src/index.ts", "typecheck": "tsc --noEmit", @@ -12,3 +13,4 @@ "tsx": "^4.7.0", "typescript": "^5.5.0" } +} diff --git a/examples/university-admissions/package.json b/examples/university-admissions/package.json index 26b6175..894cc9c 100644 --- a/examples/university-admissions/package.json +++ b/examples/university-admissions/package.json @@ -1,3 +1,4 @@ +{ "scripts": { "start": "tsx src/index.ts", "typecheck": "tsc --noEmit", @@ -12,3 +13,4 @@ "tsx": "^4.7.0", "typescript": "^5.5.0" } +} diff --git a/examples/visa-appointment/package.json b/examples/visa-appointment/package.json index 26b6175..894cc9c 100644 --- a/examples/visa-appointment/package.json +++ b/examples/visa-appointment/package.json @@ -1,3 +1,4 @@ +{ "scripts": { "start": "tsx src/index.ts", "typecheck": "tsc --noEmit", @@ -12,3 +13,4 @@ "tsx": "^4.7.0", "typescript": "^5.5.0" } +} diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 02687c6..6ccb7ff 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -67,6 +67,12 @@ export interface QueueDeploymentParams { escrowAsset?: string; escrowAmountReadable?: number; wasmHash?: string; + /** + * Optional 32-byte deployment salt (hex-encoded string of length 64). + * When omitted, a random salt is generated client-side so every deployment + * produces a deterministic-but-unique contract address (front-running safe). + */ + salt?: string; } export enum AdvancementRule {